mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 14:38:12 +02:00
Compare commits
144
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -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
|
||||
@@ -45,5 +56,8 @@ jobs:
|
||||
LOKI_VERSION=${{ github.sha }}
|
||||
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
|
||||
+44
-15
@@ -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,19 +42,37 @@ 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 bis : whisper-cli (dictée vocale) ───────────────────────────
|
||||
# whisper.cpp compilé CPU seul, en statique : un binaire unique, aucune .so à
|
||||
# trimballer, et pas de compilation CUDA (des minutes de build pour un gain
|
||||
# nul sur des dictées de quelques secondes). Ubuntu 22.04 : glibc plus
|
||||
# ancienne que l'image runtime, donc compatible quoi qu'elle embarque.
|
||||
FROM ubuntu:22.04 AS whisperbuild
|
||||
# ── É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 \
|
||||
build-essential cmake git ca-certificates \
|
||||
curl ca-certificates bzip2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \
|
||||
&& cmake -S /w -B /w/build -DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_SHARED_LIBS=OFF -DWHISPER_BUILD_TESTS=OFF \
|
||||
&& cmake --build /w/build -j --target whisper-cli
|
||||
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
|
||||
@@ -98,9 +127,9 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
|
||||
|
||||
COPY --from=gobuild /out/loki /usr/local/bin/loki
|
||||
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
|
||||
# Dictée vocale (POST /api/transcribe). Le modèle (~190 Mo) n'est PAS dans
|
||||
# l'image : téléchargé au premier usage dans /data/whisper/.
|
||||
COPY --from=whisperbuild /w/build/bin/whisper-cli /usr/local/bin/whisper-cli
|
||||
# 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
|
||||
|
||||
@@ -109,7 +138,7 @@ ENV LOKI_CONTAINER=1 \
|
||||
LOKI_HOME=/data \
|
||||
LOKI_MODEL_DIRS=/models \
|
||||
LOKI_ENGINE_BIN=/app/llama-server \
|
||||
LOKI_WHISPER_BIN=/usr/local/bin/whisper-cli \
|
||||
LOKI_ASR_SERVER=/usr/local/bin/sherpa-onnx-offline-websocket-server \
|
||||
LD_LIBRARY_PATH=/app \
|
||||
NVIDIA_VISIBLE_DEVICES=all \
|
||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
|
||||
@@ -26,7 +26,31 @@ 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.
|
||||
|
||||
## Services externes
|
||||
|
||||
|
||||
@@ -122,9 +122,20 @@ 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 | — |
|
||||
|
||||
**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 +148,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 +167,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 +209,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 +247,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 +321,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 +356,374 @@ 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.
|
||||
- **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. 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 (date, réglages), 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 ; 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 (« inconnu » :
|
||||
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.
|
||||
VRAM inconnue (Mac Intel, AMD, Vulkan, `--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é au
|
||||
démarrage de l'interface s'il tournait avant. **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. **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).
|
||||
- **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 +759,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
|
||||
|
||||
+74
-34
@@ -1,50 +1,90 @@
|
||||
# Loki 0.12.0
|
||||
# Loki 0.14.0
|
||||
|
||||
Le sélecteur de modèle devient un vrai sélecteur, la dictée vocale arrive, et
|
||||
les cartes de raisonnement suivent l'écriture au lieu d'envahir la page.
|
||||
Loki rattrape AJEAN jusqu'à la 0.17.6 et reprend une série d'idées d'OpenFox
|
||||
pour le mode Code. Le gros du travail est invisible : des discussions qui ne se
|
||||
bloquent plus sur un contexte plein, des réponses qui survivent à une coupure
|
||||
réseau, un agent de code qui gaspille moins de tours.
|
||||
|
||||
## Le sélecteur de l'en-tête charge vraiment un modèle
|
||||
## Le contexte ne bloque plus la discussion
|
||||
|
||||
Deux groupes : « Presets » et « Modèles (.gguf) » — tous les fichiers de
|
||||
`/models` et `/data/models`. Choisir un modèle le charge (seul `MODEL` change,
|
||||
contexte/NGL/échantillonnage conservés) et redémarre le moteur. Sans preset
|
||||
créé, le sélecteur n'offrait aucun choix.
|
||||
Sur une fenêtre de 65k, un tour qui lisait plusieurs gros fichiers d'un coup
|
||||
passait de 60 % à plus de 130 % sans jamais compacter, et le moteur répondait
|
||||
par un 400 qui figeait la discussion.
|
||||
|
||||
## Dictée vocale
|
||||
- Le compactage en cours de tour compte aussi les résultats d'outils que le
|
||||
moteur n'a pas encore vus.
|
||||
- Tout résultat d'outil est borné à 30 000 caractères **pour le modèle** ;
|
||||
l'interface garde le résultat complet (« voir plus »).
|
||||
- En dernier recours, les gros résultats sont tronqués puis les plus vieux
|
||||
échanges retirés, au lieu de laisser remonter l'erreur.
|
||||
- Le compactage ne fait plus répondre deux fois à une vieille question, et le
|
||||
texte écrit avant un appel d'outil n'est plus rangé en double.
|
||||
|
||||
Un bouton micro dans la carte de saisie : tu parles, le texte s'écrit. La
|
||||
transcription est 100 % locale — whisper.cpp compilé dans l'image, modèle
|
||||
multilingue téléchargé au premier usage (~190 Mo, dans `/data/whisper/`).
|
||||
Le navigateur exige HTTPS (ou localhost) pour donner accès au micro.
|
||||
## Presets : API compatible OpenAI
|
||||
|
||||
## Cartes raisonnement/outils : taille fixe, défilement qui suit
|
||||
Les presets d'API externe gagnent ce qui leur manquait :
|
||||
|
||||
Hauteur bornée (280 px) avec défilement interne collé au texte pendant la
|
||||
génération — remonter lire le début est respecté. Les cartes d'outil suivent
|
||||
la ligne en cours d'écriture puis remontent en tête une fois l'appel terminé.
|
||||
Sur un raisonnement géant, seul le bas du bloc est re-rendu en direct (le
|
||||
texte complet est posé à la fin) : l'affichage ne se fige plus.
|
||||
- une case **« le modèle accepte les images »** pour un modèle distant
|
||||
multimodal ;
|
||||
- plus de `chat_template_kwargs` (propre à llama.cpp) envoyé à l'API : une API
|
||||
stricte répondait 400 et **chaque compactage échouait** ;
|
||||
- **reprise automatique** quand le flux se coupe en pleine réponse (Wi-Fi, VPN,
|
||||
proxy) : jusqu'à 5 fois, le modèle reprend là où il s'était arrêté ;
|
||||
- un 401, 429 ou 502 n'est plus pris pour un appel d'outil mal formé ;
|
||||
- le débit est mesuré même quand l'API ne le donne pas ; le benchmark refuse de
|
||||
mesurer un moteur local qui n'est pas celui du preset ; le badge des réponses
|
||||
porte le nom du modèle distant.
|
||||
|
||||
## Mesures
|
||||
## Mode Code
|
||||
|
||||
- La carte du temps total affiche la **vitesse moyenne de la conversation**
|
||||
(« en cours — 12 s · moy 21.3 tok/s »), rejouée au rechargement.
|
||||
- Le **pourcentage de chargement** du modèle bouge aussi sur un redémarrage à
|
||||
chaud (modèle déjà dans le cache disque : on suit la mémoire résidente, plus
|
||||
seulement les octets lus).
|
||||
- **La vérification attend que le builder ait fini.** Il marque chaque critère
|
||||
`completed` une fois fait ; tant qu'il en reste d'ouverts, il est relancé au
|
||||
lieu de payer une passe de vérification sur un travail inachevé. Plus aucune
|
||||
vérification ne part quand il vient de poser une question.
|
||||
- **Écriture refusée dès son chemin** : un `write` sur un fichier jamais lu
|
||||
était refusé après tout le fichier généré. Le chemin passe maintenant en
|
||||
premier et le refus coupe la génération aussitôt.
|
||||
- **Appel d'outil écrit en texte** (`<tool_call>`, format XML de Qwen3-Coder) :
|
||||
repéré pendant le flux, coupé, et la relance cite l'extrait fautif.
|
||||
- `read_file`, `str_replace`, `old_string`… appris d'autres agents sont
|
||||
traduits vers les vrais outils.
|
||||
- Les consignes du dépôt (**AGENTS.md**, CLAUDE.md) font partie du contexte.
|
||||
- Terminal : `cmd | tail -N` rend le **vrai** code de sortie, la sortie déjà
|
||||
produite est gardée au délai dépassé, les couleurs ANSI sont retirées.
|
||||
- `git_status` / `git_diff` trouvent le dépôt cloné dans un sous-dossier.
|
||||
- Le compactage garde les fichiers touchés, les erreurs résolues et l'état des
|
||||
critères.
|
||||
|
||||
## Interface
|
||||
## Tâches et chat
|
||||
|
||||
- Discussions triées par date de **création** (récentes en tête) : une
|
||||
discussion garde sa place, écrire dans un vieux fil ne le fait plus remonter.
|
||||
- Bouton **Réglages** ancré en pied de barre latérale, au-dessus du moniteur
|
||||
Performance.
|
||||
- **« Rappelle-moi dans 20 minutes »** : `task_create` accepte `in_minutes` ou
|
||||
`at`, à l'heure du navigateur (le conteneur tourne en UTC).
|
||||
- **Écrire pendant que l'IA répond** : le message part en file et le modèle en
|
||||
tient compte dans la suite de sa réponse ; deux appareils qui envoient en
|
||||
même temps ne se refusent plus.
|
||||
- Longues discussions : au chargement, les 20 derniers échanges, le début d'un
|
||||
clic ; la liste des discussions se dessine par pages et montre la nouvelle
|
||||
dès le premier message.
|
||||
- Mémoire : un mode par projet, et un quatrième, « recherche ».
|
||||
- Appels d'outils parallèles séparés selon leur index ; bascule de preset par
|
||||
identifiant ; un raisonnement qui reprend après la réponse a sa propre bulle.
|
||||
|
||||
## Rappel 0.11.0
|
||||
## Sécurité et données
|
||||
|
||||
Réglages en modale à deux volets ; nom du modèle sur chaque réponse
|
||||
(journalisé, il survit au rechargement) ; jeton Hugging Face réglable dans
|
||||
l'interface ; tâches planifiées reprises de l'amont.
|
||||
- **Un site tiers ne peut plus piloter Loki** depuis le navigateur (Origin /
|
||||
Sec-Fetch-Site, DNS rebinding). ⚠️ Derrière un reverse proxy par nom de
|
||||
domaine, définis une clé de pilotage ou `LOKI_TRUSTED_HOSTS` **avant** la mise
|
||||
à jour, et vérifie que le proxy transmet l'en-tête `Host` d'origine.
|
||||
- **Chiffrement de la mémoire** : l'activer rendait illisibles la discussion
|
||||
active et le mode Code. Corrigé ; une base déjà touchée est réparée au
|
||||
déverrouillage. Les résultats d'outils (« voir plus ») et les images du fil
|
||||
sont désormais chiffrés eux aussi, et les images effacées après 24 h.
|
||||
|
||||
## Moteur
|
||||
|
||||
- `--mlock` seul ne coupe plus le mmap (OOM au chargement).
|
||||
- Port déjà occupé refusé avec un message clair ; cache KV lent signalé.
|
||||
- Réponses web compressées en gzip.
|
||||
|
||||
## Mise à jour
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -2,14 +2,14 @@
|
||||
"FixedFileInfo": {
|
||||
"FileVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 12,
|
||||
"Patch": 3,
|
||||
"Minor": 14,
|
||||
"Patch": 0,
|
||||
"Build": 0
|
||||
},
|
||||
"ProductVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 12,
|
||||
"Patch": 3,
|
||||
"Minor": 14,
|
||||
"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.12.3",
|
||||
"ProductVersion": "0.14.0",
|
||||
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
@@ -34,4 +34,4 @@
|
||||
"CharsetID": "04B0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,32 @@ 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=
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+19
-1
@@ -18,7 +18,25 @@ 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.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||
@@ -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.
|
||||
@@ -92,6 +92,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,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
|
||||
|
||||
@@ -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 -------------------------------------------------
|
||||
@@ -382,7 +414,16 @@ 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())
|
||||
@@ -462,6 +503,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 +595,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 +615,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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -189,7 +189,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 +205,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 +232,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 +343,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 +354,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 +393,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 +441,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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+539
-93
@@ -4,15 +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
|
||||
@@ -22,9 +25,11 @@ 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 {
|
||||
return strings.Contains(binHelp(bin), "--reasoning ")
|
||||
// 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
|
||||
@@ -56,20 +61,19 @@ func binHelp(bin string) string {
|
||||
return h
|
||||
}
|
||||
|
||||
// binSupportsLoadMode : les moteurs récents ont fusionné --mlock, --mmap et
|
||||
// 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 binSupportsLoadMode(bin string) bool {
|
||||
return strings.Contains(binHelp(bin), "--load-mode")
|
||||
func helpSupportsLoadMode(help string) bool {
|
||||
return strings.Contains(help, "--load-mode")
|
||||
}
|
||||
|
||||
// binSupportsNGLAuto : « -ngl auto » laisse llama.cpp mesurer la VRAM libre et
|
||||
// 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 binSupportsNGLAuto(bin string) bool {
|
||||
h := binHelp(bin)
|
||||
func helpSupportsNGLAuto(h string) bool {
|
||||
i := strings.Index(h, "--n-gpu-layers")
|
||||
if i < 0 {
|
||||
i = strings.Index(h, "-ngl")
|
||||
@@ -84,16 +88,16 @@ func binSupportsNGLAuto(bin string) bool {
|
||||
return strings.Contains(h[i:end], "'auto'")
|
||||
}
|
||||
|
||||
// binFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
|
||||
// helpFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
|
||||
//
|
||||
// La lecture fine de l'aide (binSupportsNGLAuto) reste la source de vérité, mais
|
||||
// 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 binFitsLayersItself(bin string) bool {
|
||||
return binSupportsNGLAuto(bin) || binSupportsLoadMode(bin)
|
||||
func helpFitsLayersItself(help string) bool {
|
||||
return helpSupportsNGLAuto(help) || helpSupportsLoadMode(help)
|
||||
}
|
||||
|
||||
// nglArgs traduit la valeur NGL du preset en arguments, et renvoie au passage la
|
||||
@@ -140,22 +144,37 @@ func hasAnyFlag(args []string, flags ...string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeLoadFlags traduit les drapeaux de chargement dépréciés vers
|
||||
// --load-mode, sur les moteurs qui le connaissent :
|
||||
// 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).
|
||||
//
|
||||
// --mlock → --load-mode mlock (garder le modèle en RAM)
|
||||
// --no-mmap → --load-mode none (tout charger, pas de mmap)
|
||||
// --mmap → --load-mode mmap
|
||||
// Vers --load-mode (moteur récent). Deux interrupteurs → une seule valeur, au
|
||||
// sens où llama.cpp l'entend (« mlock » = PAS de mmap + résident) :
|
||||
//
|
||||
// Les deux à la fois (--mlock --no-mmap) : mlock l'emporte, c'est l'intention la
|
||||
// plus forte (résident en RAM, jamais rendu au système). Un --load-mode déjà
|
||||
// écrit à la main dans EXTRA_ARGS gagne sur tout : on retire alors simplement
|
||||
// les vieux drapeaux. Sur un moteur ancien, rien n'est touché.
|
||||
func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
|
||||
// --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 args
|
||||
return downgradeLoadMode(args)
|
||||
}
|
||||
explicit := hasAnyFlag(args, "--load-mode")
|
||||
explicit := hasAnyFlag(args, "--load-mode", "-lm")
|
||||
mlock, nommap, mmap := false, false, false
|
||||
out := make([]string, 0, len(args)+2)
|
||||
for _, a := range args {
|
||||
@@ -171,23 +190,88 @@ func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
|
||||
}
|
||||
}
|
||||
if explicit || !(mlock || nommap || mmap) {
|
||||
return out
|
||||
return out, ""
|
||||
}
|
||||
mode := "mmap"
|
||||
switch {
|
||||
case mlock:
|
||||
case mlock && nommap:
|
||||
mode = "mlock"
|
||||
case mlock:
|
||||
mode = "mmap+mlock"
|
||||
case nommap:
|
||||
mode = "none"
|
||||
}
|
||||
return append(out, "--load-mode", mode)
|
||||
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
|
||||
}
|
||||
bin := cfg["BIN"]
|
||||
if bin == "" {
|
||||
return fmt.Errorf("BIN non défini — lance « loki edit »")
|
||||
@@ -230,40 +314,314 @@ 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))
|
||||
|
||||
si := serveSysInfo{Help: binHelp(bin), Model: model}
|
||||
// Vision : le projecteur multimodal (mmproj-*.gguf) donne des yeux au modèle.
|
||||
// C'est un fichier .gguf À PART du modèle, chargé via --mmproj. On le résout
|
||||
// comme le modèle (nom simple cherché dans les dossiers déclarés, ou chemin
|
||||
// absolu), pour qu'un preset écrit sous Windows reste lançable ailleurs et que
|
||||
// le champ « Vision » de l'interface n'ait qu'à écrire le nom du fichier.
|
||||
// Introuvable = on préfère le dire clairement plutôt que laisser llama-server
|
||||
// mourir en boucle sur un « failed to load mmproj » cryptique.
|
||||
if mm := strings.TrimSpace(cfg["MMPROJ"]); mm != "" {
|
||||
mmPath, err := resolveServeModelPath(mm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("projecteur vision introuvable : %s (%v)", mm, err)
|
||||
}
|
||||
if _, err := os.Stat(mmPath); err != nil {
|
||||
return fmt.Errorf("projecteur vision introuvable : %s", mmPath)
|
||||
}
|
||||
si.MMProj = mmPath
|
||||
}
|
||||
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
|
||||
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
|
||||
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
|
||||
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
|
||||
si.APIKey, _ = effectiveAPIKeyErr()
|
||||
// Conteneur à l'étroit (cpuset, quota) : seul Linux l'expose, dans /proc et
|
||||
// /sys. Un LLAMA_ARG_THREADS déjà posé est un choix de l'utilisateur — un -t
|
||||
// de Loki l'écraserait, donc on ne sonde même pas.
|
||||
if runtime.GOOS == "linux" && os.Getenv("LLAMA_ARG_THREADS") == "" {
|
||||
si.CPU = cpusetThreads(os.DirFS("/"))
|
||||
}
|
||||
|
||||
extra := splitArgs(cfg["EXTRA_ARGS"])
|
||||
// Deux GPU ou plus : de quoi décider des files de lancement CUDA (voir
|
||||
// launchQueuesEnv). Après la sélection GPU, qui fait partie de la réponse.
|
||||
probeServeGPUs(cfg, extra, &si)
|
||||
probeExpertEnv(&si)
|
||||
probeLoadEnv(&si)
|
||||
probeFidelityEnv(&si)
|
||||
probeCacheRAM(cfg, extra, &si)
|
||||
probeCkptEnv(&si)
|
||||
probeSideSlotEnv(cfg, &si)
|
||||
// SPLIT_MODE=tensor seulement : cartes listées par CE moteur, dans son ordre.
|
||||
probeSplit(cfg, bin, &si)
|
||||
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 fmt.Errorf("%s", refuse)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// 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)
|
||||
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
|
||||
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
|
||||
}
|
||||
kv := get("KV_TYPE", "")
|
||||
ktv := get("KV_TYPE_K", kv)
|
||||
vtv := get("KV_TYPE_V", kv)
|
||||
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 := normalizeLoadFlags(splitArgs(cfg["EXTRA_ARGS"]), binSupportsLoadMode(bin))
|
||||
extra, loadNote := normalizeLoadFlags(extra, helpSupportsLoadMode(si.Help))
|
||||
if loadNote != "" {
|
||||
notes = append(notes, loadNote)
|
||||
}
|
||||
|
||||
llmArgs := []string{bin,
|
||||
"-m", model,
|
||||
"-c", get("CTX", "32768"),
|
||||
"-t", get("THREADS", "0"),
|
||||
"-tb", get("THREADS_BATCH", "0"),
|
||||
// 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.
|
||||
@@ -272,8 +630,15 @@ func cmdServe(args []string) error {
|
||||
// uniquement à cause de ce nouveau défaut. PARALLEL=n dans le preset pour qui
|
||||
// veut vraiment servir plusieurs requêtes à la fois — ou --parallel dans
|
||||
// EXTRA_ARGS, auquel cas on ne redouble pas le drapeau.
|
||||
if !hasAnyFlag(extra, "--parallel", "-np") {
|
||||
llmArgs = append(llmArgs, "--parallel", get("PARALLEL", "1"))
|
||||
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 :
|
||||
//
|
||||
@@ -295,35 +660,29 @@ func cmdServe(args []string) error {
|
||||
// ci-dessus. Sur un moteur qui connaît « auto », 999 devient donc auto, et on
|
||||
// le DIT sur stderr plutôt que de le faire en douce. Qui veut réellement
|
||||
// forcer tout sur le GPU écrit NGL=all (ou un nombre qui n'est pas 999).
|
||||
if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
|
||||
args, note := nglArgs(get("NGL", ""), binFitsLayersItself(bin))
|
||||
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 != "" {
|
||||
fmt.Fprintln(os.Stderr, "[loki serve] "+note)
|
||||
notes = append(notes, note)
|
||||
}
|
||||
llmArgs = append(llmArgs, args...)
|
||||
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 != "" {
|
||||
llmArgs = append(llmArgs, "-ctk", ktv)
|
||||
args = append(args, "-ctk", ktv)
|
||||
}
|
||||
if vtv != "" {
|
||||
llmArgs = append(llmArgs, "-ctv", vtv)
|
||||
args = append(args, "-ctv", vtv)
|
||||
}
|
||||
// Vision : le projecteur multimodal (mmproj-*.gguf) donne des yeux au modèle.
|
||||
// C'est un fichier .gguf À PART du modèle, chargé via --mmproj. On le résout
|
||||
// comme le modèle (nom simple cherché dans les dossiers déclarés, ou chemin
|
||||
// absolu), pour qu'un preset écrit sous Windows reste lançable ailleurs et que
|
||||
// le champ « Vision » de l'interface n'ait qu'à écrire le nom du fichier.
|
||||
// Introuvable = on préfère le dire clairement plutôt que laisser llama-server
|
||||
// mourir en boucle sur un « failed to load mmproj » cryptique.
|
||||
if mm := strings.TrimSpace(cfg["MMPROJ"]); mm != "" {
|
||||
mmPath, err := resolveServeModelPath(mm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("projecteur vision introuvable : %s (%v)", mm, err)
|
||||
}
|
||||
if _, err := os.Stat(mmPath); err != nil {
|
||||
return fmt.Errorf("projecteur vision introuvable : %s", mmPath)
|
||||
}
|
||||
llmArgs = append(llmArgs, "--mmproj", mmPath)
|
||||
// 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 :
|
||||
//
|
||||
@@ -343,37 +702,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 (déjà découpé plus haut comme le ferait le shell — les guillemets
|
||||
// gardent ensemble un chemin qui contient des espaces) ferme la marche.
|
||||
llmArgs = append(llmArgs, extra...)
|
||||
|
||||
// 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,538 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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 {
|
||||
if !hasTool("nvidia-smi") {
|
||||
return 0
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
out, err := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index,memory.total",
|
||||
"--format=csv,noheader,nounits")).Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return vramFromSMI(string(out), cvd, pciOrder)
|
||||
}
|
||||
|
||||
// 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,264 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHasAnyFlag(t *testing.T) {
|
||||
@@ -31,16 +33,25 @@ func TestNormalizeLoadFlags(t *testing.T) {
|
||||
supports bool
|
||||
want []string
|
||||
}{
|
||||
{"moteur ancien : rien ne bouge", []string{"--mlock", "--no-mmap"}, false,
|
||||
{"moteur ancien : anciens drapeaux intacts", []string{"--mlock", "--no-mmap"}, false,
|
||||
[]string{"--mlock", "--no-mmap"}},
|
||||
{"mlock traduit", []string{"--mlock", "-fa"}, true,
|
||||
[]string{"-fa", "--load-mode", "mlock"}},
|
||||
// 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 l'emporte sur no-mmap", []string{"--no-mmap", "--mlock"}, true,
|
||||
{"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"}},
|
||||
@@ -49,10 +60,15 @@ func TestNormalizeLoadFlags(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := normalizeLoadFlags(c.in, c.supports)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -91,3 +107,32 @@ func TestNGLArgs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgValueEtPortLibre(t *testing.T) {
|
||||
if got := argValue([]string{"--port", "8080", "-c", "1", "--port", "9000"}, "--port"); got != "9000" {
|
||||
t.Fatalf("la dernière occurrence doit gagner, got %q", got)
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, port, _ := net.SplitHostPort(ln.Addr().String())
|
||||
if waitPortFree("0.0.0.0", port, 600*time.Millisecond) == nil {
|
||||
t.Fatal("un port occupé doit être refusé")
|
||||
}
|
||||
ln.Close()
|
||||
if err := waitPortFree("0.0.0.0", port, time.Second); err != nil {
|
||||
t.Fatalf("un port libre doit passer : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlowKV(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
k, v string
|
||||
lent bool
|
||||
}{{"", "", false}, {"q8_0", "q8_0", false}, {"q4_0", "q4_0", false}, {"q8_0", "q4_0", true}, {"q5_1", "q5_1", true}} {
|
||||
if slowKV(c.k, c.v) != c.lent {
|
||||
t.Errorf("%s/%s : lent attendu %v", c.k, c.v, c.lent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
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 := int64(0), modelMiB, true
|
||||
switch {
|
||||
case remote:
|
||||
case si.UnifiedMem:
|
||||
low, est, upTo = modelMiB, modelMiB, false
|
||||
case si.VRAMMiB > 0:
|
||||
low = max(modelMiB-si.VRAMMiB, 0)
|
||||
est, upTo = low, false
|
||||
}
|
||||
ram := si.RAMMiB
|
||||
what := "environ "
|
||||
if upTo {
|
||||
what = "jusqu'à "
|
||||
}
|
||||
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 "", ""
|
||||
}
|
||||
|
||||
// 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)
|
||||
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,285 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"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, VRAMMiB: 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, VRAMMiB: 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, VRAMMiB: 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, VRAMMiB: 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, VRAMMiB: 16 << 10, RAMMiB: 16 << 10}, refuse: true},
|
||||
{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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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, VRAMMiB: 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// État du slot gardé à travers une bascule de preset (SLOT_PERSIST, off par
|
||||
// défaut).
|
||||
//
|
||||
// Basculer d'un preset A vers B puis revenir à A relance llama-server deux
|
||||
// fois : au retour, la conversation de A est recalculée en entier — 30 à 80 s
|
||||
// sur un 27B dense. Avec SLOT_PERSIST=on, Loki demande au moteur, juste avant
|
||||
// la bascule, d'écrire l'état du slot 0 sur disque (/slots/0?action=save), et
|
||||
// le recharge au retour, avant la première requête de la discussion
|
||||
// (llm_slotpersist.go). llama-server ne reprend un état que sur un préfixe de
|
||||
// jetons identique : au pire, rien n'est repris.
|
||||
//
|
||||
// Mais rien, dans le fichier, ne dit quel moteur ni quels réglages ont calculé
|
||||
// ce cache : llama.cpp ne vérifie que les types et les tailles. Le recharger
|
||||
// sous un autre build, une autre fenêtre ou un autre placement donnerait au
|
||||
// modèle un contexte que ce moteur n'aurait jamais produit. D'où la clé : une
|
||||
// empreinte de TOUT ce qui touche au calcul — ligne de commande complète
|
||||
// (modèle, fenêtre, types KV, NGL, lots, gabarit, EXTRA_ARGS…), variables
|
||||
// LLAMA_ARG_*, GGML_* et CUDA_*, identité (taille, date) du modèle et de ses
|
||||
// tranches, du projecteur, du brouillon, du binaire et de ses bibliothèques.
|
||||
// Un fichier ne se recharge que sous la même clé ; une mise à jour du moteur
|
||||
// change donc tout, et n'est jamais un cas de reprise.
|
||||
//
|
||||
// Refusé d'emblée : le décodage spéculatif (la sauvegarde de llama.cpp ne
|
||||
// garde que le modèle cible, pas le brouillon MTP — l'état rechargé serait
|
||||
// incohérent pour lui), les poids sur CPU (MoE déporté : états de plusieurs
|
||||
// Gio pour un recalcul de toute façon lent), et un moteur joignable par
|
||||
// d'autres sans clé d'API : save et restore écrivent des Gio sur le disque.
|
||||
|
||||
// slotPersistOn : la clé SLOT_PERSIST est-elle posée ?
|
||||
func slotPersistOn(cfg map[string]string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(cfg["SLOT_PERSIST"])) {
|
||||
case "on", "1", "true", "yes", "oui":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// slotPersistArgEnv : variables qui contredisent SLOT_PERSIST sans drapeau.
|
||||
var slotPersistArgEnv = []string{"LLAMA_ARG_SLOT_SAVE_PATH", "LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL",
|
||||
"LLAMA_ARG_SPEC_DRAFT_HF_REPO"}
|
||||
|
||||
// probeSlotPersistEnv complète ArgEnv, seulement si la clé est posée.
|
||||
func probeSlotPersistEnv(cfg map[string]string, si *serveSysInfo) {
|
||||
if !slotPersistOn(cfg) {
|
||||
return
|
||||
}
|
||||
if si.ArgEnv == nil {
|
||||
si.ArgEnv = map[string]string{}
|
||||
}
|
||||
for _, k := range slotPersistArgEnv {
|
||||
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// slotPersistPlan : SLOT_PERSIST vaut-il pour ce lancement ? why dit pourquoi
|
||||
// non (vide = clé absente).
|
||||
func slotPersistPlan(cfg map[string]string, extra []string, si serveSysInfo) (ok bool, why string) {
|
||||
if !slotPersistOn(cfg) {
|
||||
return false, ""
|
||||
}
|
||||
switch {
|
||||
case isExternalConfig(cfg):
|
||||
return false, "preset externe"
|
||||
case !strings.Contains(si.Help, "--slot-save-path"):
|
||||
return false, "ce moteur ne connaît pas --slot-save-path"
|
||||
case hasAnyFlag(extra, "--slot-save-path") || si.ArgEnv["LLAMA_ARG_SLOT_SAVE_PATH"] != "":
|
||||
return false, "--slot-save-path est déjà réglé à la main"
|
||||
case si.SlotDir == "":
|
||||
return false, "dossier des slots indisponible"
|
||||
case specMode(cfg) != "off" || specUserSet(extra, si.ArgEnv) || si.ArgEnv["LLAMA_ARG_SPEC_TYPE"] != "":
|
||||
return false, "décodage spéculatif : la sauvegarde du moteur ne garde pas l'état du brouillon"
|
||||
case splitModeKey(cfg) != "":
|
||||
return false, "SPLIT_MODE : état réparti entre cartes, sauvegarde non validée"
|
||||
}
|
||||
if !slotSaveSafe(cfg, extra, si) {
|
||||
return false, "moteur joignable par d'autres sans clé d'API (HOST=127.0.0.1 ou une clé d'API)"
|
||||
}
|
||||
blocks := 0
|
||||
if si.GGUF != nil {
|
||||
blocks = si.GGUF.BlockCount
|
||||
}
|
||||
if r := cpuWeights(cfg, extra, si.ArgEnv, blocks); r != "" {
|
||||
return false, r
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// slotSaveSafe : le moteur n'est joignable que par la machine, ou exige une
|
||||
// clé d'API. Sans ça, --slot-save-path donnerait à n'importe qui le droit
|
||||
// d'écrire des Gio sur le disque.
|
||||
func slotSaveSafe(cfg map[string]string, extra []string, si serveSysInfo) bool {
|
||||
host := flagValue(extra, "--host")
|
||||
if host == "" {
|
||||
host = cfg["HOST"]
|
||||
}
|
||||
if host == "" {
|
||||
host = "0.0.0.0"
|
||||
}
|
||||
return loopbackHost(host) || si.APIKey != ""
|
||||
}
|
||||
|
||||
// slotPersistNote : ce que le lancement dit de SLOT_PERSIST.
|
||||
func slotPersistNote(ok bool, why string) string {
|
||||
if ok || why == "" {
|
||||
return ""
|
||||
}
|
||||
return "SLOT_PERSIST refusé (" + why + ") : l'état du slot ne sera pas gardé à la bascule de preset"
|
||||
}
|
||||
|
||||
// slotPersistFileRe : les seuls fichiers que Loki écrit dans le dossier des
|
||||
// slots — clé du moteur, empreinte de la discussion.
|
||||
var slotPersistFileRe = regexp.MustCompile(`^persist-[0-9a-f]{16}-[0-9a-f]{12}\.bin$`)
|
||||
|
||||
// slotPersistMaxFiles : au plus deux états gardés (A→B→A, puis B→A→B).
|
||||
const slotPersistMaxFiles = 2
|
||||
|
||||
// slotPersistKey : l'empreinte de tout ce qui décide du calcul du cache.
|
||||
// args : la ligne de commande finale (la clé d'API n'y entre pas) ; env : ce
|
||||
// que buildServeArgs pose. Les fichiers sont pris par taille et date : un
|
||||
// modèle retéléchargé, un moteur mis à jour changent la clé.
|
||||
func slotPersistKey(args []string, env map[string]string, si serveSysInfo, bin string) string {
|
||||
h := sha256.New()
|
||||
w := func(s string) { h.Write([]byte(s)); h.Write([]byte{0}) }
|
||||
for i := 0; i < len(args); i++ {
|
||||
if args[i] == "--api-key" {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
w(args[i])
|
||||
}
|
||||
vars := map[string]string{}
|
||||
for _, kv := range os.Environ() {
|
||||
k, v, _ := strings.Cut(kv, "=")
|
||||
if strings.HasPrefix(k, "LLAMA_ARG_") || strings.HasPrefix(k, "GGML_") || strings.HasPrefix(k, "CUDA_") {
|
||||
vars[k] = v
|
||||
}
|
||||
}
|
||||
for k, v := range env {
|
||||
vars[k] = v
|
||||
}
|
||||
delete(vars, "LLAMA_ARG_API_KEY")
|
||||
keys := make([]string, 0, len(vars))
|
||||
for k := range vars {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
w(k + "=" + vars[k])
|
||||
}
|
||||
stat := func(p string) {
|
||||
if p == "" {
|
||||
return
|
||||
}
|
||||
if st, err := os.Stat(p); err == nil {
|
||||
w(fmt.Sprintf("%s|%d|%d", p, st.Size(), st.ModTime().UnixNano()))
|
||||
} else {
|
||||
w(p + "|?")
|
||||
}
|
||||
}
|
||||
if si.Model != "" {
|
||||
for _, n := range shardFamily(filepath.Base(si.Model)) {
|
||||
stat(filepath.Join(filepath.Dir(si.Model), n))
|
||||
}
|
||||
stat(si.Model)
|
||||
}
|
||||
stat(si.MMProj)
|
||||
stat(si.Draft)
|
||||
stat(bin)
|
||||
// Bibliothèques du moteur (libllama, ggml-cuda…) : une mise à jour peut
|
||||
// les changer sans toucher au binaire.
|
||||
if entries, err := os.ReadDir(filepath.Dir(bin)); err == nil {
|
||||
for _, e := range entries {
|
||||
n := strings.ToLower(e.Name())
|
||||
if strings.HasSuffix(n, ".so") || strings.Contains(n, ".so.") || strings.HasSuffix(n, ".dll") || strings.HasSuffix(n, ".dylib") {
|
||||
stat(filepath.Join(filepath.Dir(bin), e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
w(fmt.Sprintf("build=%d", si.EngineBuild))
|
||||
return hex.EncodeToString(h.Sum(nil))[:16]
|
||||
}
|
||||
|
||||
// slotPersistMarker : la clé du moteur lancé, écrite par « loki serve » juste
|
||||
// avant de lui céder la place, lue par le process web. Hors du dossier des
|
||||
// slots : le moteur ne peut pas l'écraser par un save.
|
||||
func slotPersistMarker(home string) string { return filepath.Join(home, "slots-engine.key") }
|
||||
|
||||
// writeSlotPersistMarker pose (key non vide) ou retire la clé du moteur.
|
||||
func writeSlotPersistMarker(home, key string) {
|
||||
p := slotPersistMarker(home)
|
||||
if key == "" {
|
||||
_ = os.Remove(p)
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(p, []byte(key+"\n"), 0o600)
|
||||
}
|
||||
|
||||
// readSlotPersistMarker : la clé du moteur en service ; vide = SLOT_PERSIST
|
||||
// refusé ou absent à son lancement.
|
||||
func readSlotPersistMarker(home string) string {
|
||||
b, err := os.ReadFile(slotPersistMarker(home))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
k := strings.TrimSpace(string(b))
|
||||
if len(k) != 16 {
|
||||
return ""
|
||||
}
|
||||
if _, err := hex.DecodeString(k); err != nil {
|
||||
return ""
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// slotPersistPrune garde les slotPersistMaxFiles états les plus récents.
|
||||
func slotPersistPrune(dir string) {
|
||||
type f struct {
|
||||
name string
|
||||
at int64
|
||||
}
|
||||
var fs []f
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.Type().IsRegular() || !slotPersistFileRe.MatchString(e.Name()) {
|
||||
continue
|
||||
}
|
||||
if info, err := e.Info(); err == nil {
|
||||
fs = append(fs, f{e.Name(), info.ModTime().UnixNano()})
|
||||
}
|
||||
}
|
||||
sort.Slice(fs, func(i, j int) bool { return fs[i].at > fs[j].at })
|
||||
for i := slotPersistMaxFiles; i < len(fs); i++ {
|
||||
_ = os.Remove(filepath.Join(dir, fs[i].name))
|
||||
}
|
||||
}
|
||||
|
||||
// slotPersistPurge retire tous les états gardés : la clé n'est plus là, ils
|
||||
// n'ont plus rien à faire sur le disque.
|
||||
func slotPersistPurge(dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Type().IsRegular() && slotPersistFileRe.MatchString(e.Name()) {
|
||||
_ = os.Remove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Files de lancement CUDA (CUDA_SCALE_LAUNCH_QUEUES) sur deux GPU ou plus.
|
||||
//
|
||||
// Quand llama.cpp répartit les couches entre plusieurs cartes et que le modèle
|
||||
// y tient en entier, il fait travailler les cartes en pipeline : pendant que la
|
||||
// seconde calcule un micro-lot, la première attaque le suivant. Encore faut-il
|
||||
// que le CPU puisse empiler assez de lancements CUDA d'avance ; avec la file par
|
||||
// défaut du pilote, il bloque et le pipeline se vide. « 4x » quadruple cette
|
||||
// file (PR llama.cpp #19042) : c'est une affaire de pilote, pas de calcul — mêmes
|
||||
// noyaux, mêmes entrées, même ordre, donc une sortie identique au jeton près. Le
|
||||
// prix : ~120 Mo de RAM système par GPU.
|
||||
//
|
||||
// llama.cpp l'a posé par défaut puis retiré (#19227) après des blocages sur
|
||||
// Jetson (mémoire unifiée, MoE décodé en partie sur CPU) ; ses docs le
|
||||
// recommandent désormais à la main en multi-GPU. Loki le pose donc lui-même,
|
||||
// mais seulement là où le pipeline peut réellement exister (voir
|
||||
// pipelineBlocker) : ailleurs le gain est nul et l'on ne s'exposerait qu'au
|
||||
// seul cas de panne jamais rapporté.
|
||||
//
|
||||
// Gain non mesuré sur la machine de référence (5060 Ti + 3060, 27B) : en amont,
|
||||
// +10 à 25 % de prompt seulement sur un 70B réparti sur deux grosses cartes,
|
||||
// rien de mesurable sur un 8B. Le décodage ne bouge pas. Pour vérifier qu'il
|
||||
// s'applique, le journal du moteur doit dire « pipeline parallelism enabled ».
|
||||
//
|
||||
// Choix de l'utilisateur, par ordre de priorité :
|
||||
//
|
||||
// CUDA_SCALE_LAUNCH_QUEUES déjà dans l'environnement → intouché (conteneur Unraid…)
|
||||
// CUDA_LAUNCH_QUEUES=off → rien
|
||||
// CUDA_LAUNCH_QUEUES=0.25x|0.5x|2x|4x → cette valeur, sans condition
|
||||
// vide ou auto → 4x si ≥ 2 GPU et pipeline possible
|
||||
//
|
||||
// Une autre valeur serait lue comme 1x par le pilote, sans un mot : on l'ignore
|
||||
// en le disant.
|
||||
|
||||
const launchQueuesAuto = "4x"
|
||||
|
||||
// launchQueueValues : les seules valeurs que le pilote CUDA comprend.
|
||||
var launchQueueValues = map[string]bool{"0.25x": true, "0.5x": true, "2x": true, "4x": true}
|
||||
|
||||
// serveArgEnv : les variables LLAMA_ARG_* qui font, sans drapeau, ce que lit
|
||||
// pipelineBlocker. llama.cpp les applique quand le drapeau manque — les ignorer
|
||||
// ferait conclure à un pipeline que le moteur n'ouvrira pas.
|
||||
var serveArgEnv = []string{"LLAMA_ARG_DEVICE", "LLAMA_ARG_SPLIT_MODE", "LLAMA_ARG_N_GPU_LAYERS",
|
||||
"LLAMA_ARG_OVERRIDE_TENSOR", "LLAMA_ARG_CPU_MOE", "LLAMA_ARG_N_CPU_MOE", "LLAMA_ARG_N_CPU_FFN",
|
||||
"LLAMA_ARG_KV_OFFLOAD", "LLAMA_ARG_NO_KV_OFFLOAD"}
|
||||
|
||||
// launchQueuesEnv décide de CUDA_SCALE_LAUNCH_QUEUES. Fonction pure : le nombre
|
||||
// de GPU et l'environnement arrivent déjà sondés dans si. Valeur vide = ne rien
|
||||
// poser.
|
||||
func launchQueuesEnv(cfg map[string]string, extra []string, si serveSysInfo) (val string, notes []string) {
|
||||
if si.LaunchQueues != "" {
|
||||
return "", nil
|
||||
}
|
||||
want := strings.ToLower(strings.TrimSpace(cfg["CUDA_LAUNCH_QUEUES"]))
|
||||
switch {
|
||||
case want == "off":
|
||||
return "", nil
|
||||
case launchQueueValues[want]:
|
||||
return want, []string{"CUDA_SCALE_LAUNCH_QUEUES=" + want + " (CUDA_LAUNCH_QUEUES)"}
|
||||
case want != "" && want != "auto":
|
||||
notes = append(notes, "CUDA_LAUNCH_QUEUES="+cfg["CUDA_LAUNCH_QUEUES"]+
|
||||
" ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique")
|
||||
}
|
||||
n := servedGPUCount(extra, si)
|
||||
if n < 2 || pipelineBlocker(cfg, extra, si.ArgEnv) != "" {
|
||||
return "", notes
|
||||
}
|
||||
return launchQueuesAuto, append(notes, fmt.Sprintf("CUDA_SCALE_LAUNCH_QUEUES=%s (%d GPU, parallélisme pipeline "+
|
||||
"possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", launchQueuesAuto, n))
|
||||
}
|
||||
|
||||
// servedGPUCount : combien de GPU CUDA llama-server utilisera. Le --device du
|
||||
// preset d'abord (c'est là que l'éditeur range le choix des cartes), puis ce
|
||||
// que cmdServe a compté (CUDA_VISIBLE_DEVICES, sinon nvidia-smi). Un --device
|
||||
// qui ne nomme que des cartes Vulkan ou Metal compte zéro : la variable ne
|
||||
// concerne que CUDA.
|
||||
func servedGPUCount(extra []string, si serveSysInfo) int {
|
||||
dev := flagValue(extra, "-dev", "--device")
|
||||
if dev == "" {
|
||||
dev = si.ArgEnv["LLAMA_ARG_DEVICE"]
|
||||
}
|
||||
if dev == "" {
|
||||
return si.GPUs
|
||||
}
|
||||
n := 0
|
||||
for _, d := range strings.Split(dev, ",") {
|
||||
if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(d)), "CUDA") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// pipelineBlocker dit pourquoi llama.cpp n'ouvrira PAS le pipeline entre
|
||||
// cartes (llama-context.cpp : toutes les couches sur GPU, découpe par couches,
|
||||
// cache KV sur GPU, aucune surcharge de tenseurs). Vide = il peut l'ouvrir.
|
||||
// --n-cpu-moe, --cpu-moe et --n-cpu-ffn sont des surcharges de tenseurs
|
||||
// déguisées : le preset MoE à experts sur CPU n'a donc jamais de pipeline.
|
||||
//
|
||||
// Attention à la façon dont llama.cpp lit ces réglages (common/arg.cpp) : les
|
||||
// variables LLAMA_ARG_* passent d'abord, la ligne de commande ensuite. Pour un
|
||||
// réglage à valeur unique (-sm, -ngl, -kvo/-nkvo), la ligne de commande
|
||||
// l'emporte ; mais les surcharges de tenseurs S'ACCUMULENT — variable et
|
||||
// drapeaux, chaque occurrence ajoute les siennes. Un --n-cpu-moe 0 n'annule
|
||||
// donc pas un LLAMA_ARG_N_CPU_MOE=30.
|
||||
func pipelineBlocker(cfg map[string]string, extra []string, argEnv map[string]string) string {
|
||||
if r := tensorOverride(extra, argEnv); r != "" {
|
||||
return r
|
||||
}
|
||||
if !kvOffloaded(extra, argEnv) {
|
||||
return "cache KV sur CPU"
|
||||
}
|
||||
sm := flagValue(extra, "-sm", "--split-mode")
|
||||
if sm == "" {
|
||||
sm = argEnv["LLAMA_ARG_SPLIT_MODE"]
|
||||
}
|
||||
if sm != "" && sm != "layer" {
|
||||
return "découpe " + sm
|
||||
}
|
||||
// Couches GPU : celles d'EXTRA_ARGS, sinon NGL (que buildServeArgs traduit
|
||||
// toujours en -ngl, sauf NGL=auto qui ne pose rien — la variable du moteur
|
||||
// décide alors). Un nombre choisi — hors la sentinelle 999 — laisse en
|
||||
// général des couches au CPU ; auto, all et 999 visent le tout-GPU.
|
||||
ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
|
||||
if ngl == "" {
|
||||
ngl = strings.TrimSpace(cfg["NGL"])
|
||||
if strings.EqualFold(ngl, "auto") {
|
||||
ngl = argEnv["LLAMA_ARG_N_GPU_LAYERS"]
|
||||
}
|
||||
}
|
||||
if ngl = strings.ToLower(strings.TrimSpace(ngl)); ngl != "" && ngl != "auto" && ngl != "all" && ngl != "999" {
|
||||
return "couches GPU limitées à " + ngl
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// tensorOverride dit si des tenseurs sont placés à la main (-ot et ses formes
|
||||
// déguisées --cpu-moe, --n-cpu-moe, --n-cpu-ffn), en drapeau ou en variable.
|
||||
// Vide = aucun. Le pipeline entre cartes s'y arrête, et --fit aussi
|
||||
// (« tensor_buft_overrides already set by user, abort »).
|
||||
func tensorOverride(extra []string, argEnv map[string]string) string {
|
||||
if hasAnyFlag(extra, "-ot", "--override-tensor", "--cpu-moe", "-cmoe") ||
|
||||
argEnv["LLAMA_ARG_OVERRIDE_TENSOR"] != "" || envTruthy(argEnv["LLAMA_ARG_CPU_MOE"]) {
|
||||
return "surcharge de tenseurs"
|
||||
}
|
||||
for _, n := range []struct{ env, short, long string }{
|
||||
{"LLAMA_ARG_N_CPU_MOE", "-ncmoe", "--n-cpu-moe"},
|
||||
{"LLAMA_ARG_N_CPU_FFN", "-ncffn", "--n-cpu-ffn"},
|
||||
} {
|
||||
for _, v := range append(flagValues(extra, n.short, n.long), argEnv[n.env]) {
|
||||
if v = strings.TrimSpace(v); v != "" && v != "0" {
|
||||
return "couches " + n.long + " sur CPU"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// kvOffloaded : le cache KV reste-t-il sur GPU ? Le dernier -kvo/-nkvo de la
|
||||
// ligne de commande tranche ; sinon LLAMA_ARG_NO_KV_OFFLOAD, dont la SEULE
|
||||
// présence vaut « non » pour llama.cpp (quelle que soit sa valeur), puis
|
||||
// LLAMA_ARG_KV_OFFLOAD lu comme un booléen.
|
||||
func kvOffloaded(extra []string, argEnv map[string]string) bool {
|
||||
on, set := true, false
|
||||
for _, a := range extra {
|
||||
name, _, _ := strings.Cut(a, "=")
|
||||
switch name {
|
||||
case "-kvo", "--kv-offload":
|
||||
on, set = true, true
|
||||
case "-nkvo", "--no-kv-offload":
|
||||
on, set = false, true
|
||||
}
|
||||
}
|
||||
if set {
|
||||
return on
|
||||
}
|
||||
if argEnv["LLAMA_ARG_NO_KV_OFFLOAD"] != "" {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(argEnv["LLAMA_ARG_KV_OFFLOAD"])) {
|
||||
case "off", "disabled", "false", "0":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// envTruthy lit un drapeau sans valeur passé par variable, comme llama.cpp :
|
||||
// seuls on, enabled, true et 1 l'activent.
|
||||
func envTruthy(v string) bool {
|
||||
switch strings.TrimSpace(v) {
|
||||
case "on", "enabled", "true", "1":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// flagValues : les valeurs de TOUTES les occurrences d'un drapeau (« -x 1 » ou
|
||||
// « -x=1 »), pour ceux que llama.cpp cumule au lieu de garder le dernier.
|
||||
func flagValues(args []string, flags ...string) []string {
|
||||
var vals []string
|
||||
for i, a := range args {
|
||||
name, val, hasEq := strings.Cut(a, "=")
|
||||
for _, f := range flags {
|
||||
if name != f {
|
||||
continue
|
||||
}
|
||||
if hasEq {
|
||||
vals = append(vals, val)
|
||||
} else if i+1 < len(args) {
|
||||
vals = append(vals, args[i+1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return vals
|
||||
}
|
||||
|
||||
// probeServeGPUs remplit ce dont launchQueuesEnv a besoin. On ne sonde
|
||||
// nvidia-smi que si la réponse peut servir : variable déjà posée, « off », un
|
||||
// --device explicite ou CUDA_VISIBLE_DEVICES suffisent à trancher sans lui.
|
||||
func probeServeGPUs(cfg map[string]string, extra []string, si *serveSysInfo) {
|
||||
si.LaunchQueues = os.Getenv("CUDA_SCALE_LAUNCH_QUEUES")
|
||||
si.ArgEnv = map[string]string{}
|
||||
for _, k := range serveArgEnv {
|
||||
if v, ok := os.LookupEnv(k); ok && v != "" {
|
||||
si.ArgEnv[k] = v
|
||||
} else if ok && k == "LLAMA_ARG_NO_KV_OFFLOAD" {
|
||||
si.ArgEnv[k] = "1" // présente mais vide : llama.cpp n'y lit que la présence
|
||||
}
|
||||
}
|
||||
if si.LaunchQueues != "" || strings.EqualFold(strings.TrimSpace(cfg["CUDA_LAUNCH_QUEUES"]), "off") {
|
||||
return
|
||||
}
|
||||
if hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" {
|
||||
return
|
||||
}
|
||||
// cmdServe a déjà posé la sélection de config.env dans l'environnement :
|
||||
// os.Getenv voit donc le preset d'abord, le conteneur ensuite.
|
||||
if cvd := strings.TrimSpace(os.Getenv("CUDA_VISIBLE_DEVICES")); cvd != "" {
|
||||
for _, d := range strings.Split(cvd, ",") {
|
||||
if strings.TrimSpace(d) != "" {
|
||||
si.GPUs++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
si.GPUs = nvidiaGPUCount(3 * time.Second)
|
||||
}
|
||||
|
||||
// nvidiaGPUCount compte les cartes NVIDIA. Borné dans le temps : un pilote
|
||||
// coincé peut faire pendre nvidia-smi, et le démarrage du moteur ne doit pas
|
||||
// en dépendre. Au moindre doute, 0 — on ne pose rien.
|
||||
func nvidiaGPUCount(timeout time.Duration) int {
|
||||
if !hasTool("nvidia-smi") {
|
||||
return 0
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
out, err := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index", "--format=csv,noheader")).Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLaunchQueuesEnv : CUDA_SCALE_LAUNCH_QUEUES n'est posé que là où le
|
||||
// pipeline entre cartes peut exister, et jamais par-dessus un choix de
|
||||
// l'utilisateur.
|
||||
func TestLaunchQueuesEnv(t *testing.T) {
|
||||
two := serveSysInfo{GPUs: 2}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
si serveSysInfo
|
||||
want string
|
||||
wantNotes int
|
||||
}{
|
||||
{name: "2 GPU, dense tout sur GPU : 4x, et on le dit",
|
||||
cfg: map[string]string{"NGL": "999"}, si: two, want: "4x", wantNotes: 1},
|
||||
{name: "NGL=all ou auto : pipeline possible",
|
||||
cfg: map[string]string{"NGL": "all"}, si: two, want: "4x", wantNotes: 1},
|
||||
{name: "1 GPU : rien",
|
||||
cfg: map[string]string{"NGL": "999"}, si: serveSysInfo{GPUs: 1}},
|
||||
{name: "nombre de GPU inconnu : rien",
|
||||
cfg: map[string]string{}},
|
||||
{name: "variable déjà posée par l'utilisateur : intouchée",
|
||||
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "2x"}, si: serveSysInfo{GPUs: 2, LaunchQueues: "1x"}},
|
||||
{name: "CUDA_LAUNCH_QUEUES=off : rien",
|
||||
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "off"}, si: two},
|
||||
{name: "valeur explicite valide : posée telle quelle, même sur 1 GPU",
|
||||
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "2X"}, si: serveSysInfo{GPUs: 1}, want: "2x", wantNotes: 1},
|
||||
{name: "valeur invalide : ignorée, dite, puis automatique",
|
||||
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "8x"}, si: two, want: "4x", wantNotes: 2},
|
||||
{name: "valeur invalide sur 1 GPU : seulement la note",
|
||||
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "beaucoup"}, si: serveSysInfo{GPUs: 1}, wantNotes: 1},
|
||||
{name: "preset MoE (-ot, --n-cpu-moe) sur 2 GPU : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": `-ot "per_layer_token_embd=CPU" --n-cpu-moe 40`}, si: two},
|
||||
{name: "--n-cpu-moe seul : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe=12"}, si: two},
|
||||
{name: "--n-cpu-moe 0 : aucun expert sur CPU, pipeline possible",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 0"}, si: two, want: "4x", wantNotes: 1},
|
||||
{name: "--cpu-moe : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "--cpu-moe"}, si: two},
|
||||
{name: "-sm row : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "-sm row"}, si: two},
|
||||
{name: "--split-mode=layer explicite : 4x",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "--split-mode=layer"}, si: two, want: "4x", wantNotes: 1},
|
||||
{name: "NGL=28 : des couches restent au CPU, rien",
|
||||
cfg: map[string]string{"NGL": "28"}, si: two},
|
||||
{name: "-ngl 40 dans EXTRA_ARGS l'emporte sur NGL=999 : rien",
|
||||
cfg: map[string]string{"NGL": "999", "EXTRA_ARGS": "-ngl 40"}, si: two},
|
||||
{name: "cache KV sur CPU : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "-nkvo"}, si: two},
|
||||
{name: "--device d'une seule carte : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "--device CUDA0"}, si: two},
|
||||
{name: "--device de deux cartes CUDA, nvidia-smi non sondé : 4x",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "--device CUDA1,CUDA0 --tensor-split 0.6,0.4"}, want: "4x", wantNotes: 1},
|
||||
{name: "--device Vulkan : la variable ne concerne pas ce moteur",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "-dev Vulkan0,Vulkan1"}, si: two},
|
||||
{name: "--device none : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "-dev none"}, si: two},
|
||||
{name: "LLAMA_ARG_N_CPU_MOE dans l'environnement du moteur : rien",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_CPU_MOE": "30"}}},
|
||||
{name: "LLAMA_ARG_SPLIT_MODE=none : rien",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_SPLIT_MODE": "none"}}},
|
||||
{name: "NGL=auto et LLAMA_ARG_N_GPU_LAYERS=20 : rien",
|
||||
cfg: map[string]string{"NGL": "auto"}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_GPU_LAYERS": "20"}}},
|
||||
{name: "LLAMA_ARG_CPU_MOE=0 : désactivé, pipeline possible",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_CPU_MOE": "0"}}, want: "4x", wantNotes: 1},
|
||||
{name: "LLAMA_ARG_DEVICE d'une seule carte : rien",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_DEVICE": "CUDA1"}}},
|
||||
// Relecture : ce que llama.cpp fait vraiment de ses variables.
|
||||
{name: "--n-cpu-moe 0 n'annule pas LLAMA_ARG_N_CPU_MOE (surcharges cumulées) : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 0"},
|
||||
si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_CPU_MOE": "30"}}},
|
||||
{name: "deux --n-cpu-moe, le dernier à 0 : le premier compte quand même, rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 20 --n-cpu-moe 0"}, si: two},
|
||||
{name: "--n-cpu-ffn (FFN dense sur CPU) : rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "-ncffn 8"}, si: two},
|
||||
{name: "LLAMA_ARG_OVERRIDE_TENSOR : rien",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_OVERRIDE_TENSOR": "exps=CPU"}}},
|
||||
{name: "LLAMA_ARG_CPU_MOE=yes : llama.cpp ne l'active pas, 4x",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_CPU_MOE": "yes"}}, want: "4x", wantNotes: 1},
|
||||
{name: "LLAMA_ARG_NO_KV_OFFLOAD=0 : sa seule présence coupe l'offload, rien",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_NO_KV_OFFLOAD": "0"}}},
|
||||
{name: "LLAMA_ARG_KV_OFFLOAD=false : rien",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_KV_OFFLOAD": "false"}}},
|
||||
{name: "-kvo sur la ligne l'emporte sur LLAMA_ARG_NO_KV_OFFLOAD : 4x",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "-kvo"},
|
||||
si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_NO_KV_OFFLOAD": "1"}}, want: "4x", wantNotes: 1},
|
||||
{name: "-nkvo puis -kvo : le dernier gagne, 4x",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "-nkvo -kvo"}, si: two, want: "4x", wantNotes: 1},
|
||||
{name: "NGL absent : Loki passe -ngl auto, LLAMA_ARG_N_GPU_LAYERS ne compte pas, 4x",
|
||||
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_GPU_LAYERS": "20"}}, want: "4x", wantNotes: 1},
|
||||
{name: "NGL=Auto (casse libre, comme nglArgs) : 4x",
|
||||
cfg: map[string]string{"NGL": "Auto"}, si: two, want: "4x", wantNotes: 1},
|
||||
{name: "-sm tensor : pas de pipeline par couches, rien",
|
||||
cfg: map[string]string{"EXTRA_ARGS": "-sm tensor"}, si: two},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, notes := launchQueuesEnv(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
|
||||
if got != c.want {
|
||||
t.Fatalf("valeur = %q, attendu %q", got, c.want)
|
||||
}
|
||||
if len(notes) != c.wantNotes {
|
||||
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// La variable passe par l'environnement : la ligne de commande ne bouge pas
|
||||
// d'un octet, et la sélection GPU l'accompagne.
|
||||
func TestBuildServeArgsLaunchQueues(t *testing.T) {
|
||||
const bin, model = "/opt/llama/llama-server", "/models/Qwen3.6-27B-Q4_K_M.gguf"
|
||||
cfg := map[string]string{"NGL": "999", "CUDA_VISIBLE_DEVICES": "1,0"}
|
||||
si := serveSysInfo{Help: helpRecent, Model: model}
|
||||
without, _, _ := buildServeArgs(cfg, nil, bin, si)
|
||||
si.GPUs = 2
|
||||
args, env, notes := buildServeArgs(cfg, nil, bin, si)
|
||||
if !reflect.DeepEqual(args, without) {
|
||||
t.Fatalf("la ligne de commande a changé\n got %q\nwant %q", args, without)
|
||||
}
|
||||
want := map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "CUDA_DEVICE_ORDER": "PCI_BUS_ID",
|
||||
"CUDA_SCALE_LAUNCH_QUEUES": "4x"}
|
||||
if !reflect.DeepEqual(env, want) {
|
||||
t.Fatalf("env = %v, want %v", env, want)
|
||||
}
|
||||
// Une note pour 999 → auto, une pour les files de lancement.
|
||||
if len(notes) != 2 {
|
||||
t.Fatalf("notes = %q", notes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Second slot pour les travaux annexes (SIDE_SLOT, off par défaut).
|
||||
//
|
||||
// En --parallel 1, un vérificateur, un sous-agent, une tâche ou un bench
|
||||
// prennent LE slot de la conversation : son état part dans le cache RAM et
|
||||
// revient après — quand il y tient encore. Avec SIDE_SLOT=on, le moteur ouvre
|
||||
// deux slots, la conversation garde le slot 0 et tout le reste passe par le
|
||||
// slot 1 (id_slot, voir llm_sideslot.go) : l'état principal ne bouge plus du
|
||||
// tout.
|
||||
//
|
||||
// Le dimensionnement est le cœur de l'affaire. Un cache KV UNIFIÉ partagé entre
|
||||
// deux slots laisse chacun grandir jusqu'à la taille du pool : quand les deux
|
||||
// travaillent et que le pool est plein, llama.cpp renvoie « Context size has
|
||||
// been exceeded. » aux DEUX, et Loki prendrait ça pour un débordement de la
|
||||
// conversation — compaction, voire réduction, d'un fil qui tenait très bien
|
||||
// dans sa fenêtre. Et --kv-unified-per-slot plafonne tous les slots à la même
|
||||
// valeur, sans rien garantir sur la somme. On prend donc le cache NON unifié
|
||||
// (--no-kv-unified) avec -c 2×CTX : llama.cpp découpe alors le pool en deux
|
||||
// flux de CTX jetons chacun, séparés. Un débordement concurrent est impossible
|
||||
// par construction, chaque slot a exactement la fenêtre de la conversation (les
|
||||
// travaux annexes ne sont pas raccourcis), et --cache-idle-slots ne vide plus
|
||||
// les slots au repos (il ne le fait que sous cache unifié) : rien d'autre à
|
||||
// régler.
|
||||
//
|
||||
// Le prix : le cache KV (et l'état récurrent d'un hybride) en double, en VRAM.
|
||||
// On refuse donc l'option quand le compte ne tient pas, quand des poids vivent
|
||||
// en RAM (le KV en plus pousserait le placement automatique à sortir des
|
||||
// couches du GPU), et dès que l'utilisateur a réglé lui-même l'un des leviers
|
||||
// en jeu : on ne mélange pas ses réglages et les nôtres.
|
||||
|
||||
// sideSlotOn : la clé SIDE_SLOT est-elle posée ?
|
||||
func sideSlotOn(cfg map[string]string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(cfg["SIDE_SLOT"])) {
|
||||
case "on", "1", "true", "yes", "oui":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sideSlotConflictFlags : les drapeaux d'EXTRA_ARGS qui décident déjà de ce que
|
||||
// SIDE_SLOT règle. Un seul suffit à refuser.
|
||||
var sideSlotConflictFlags = []string{
|
||||
"-c", "--ctx-size",
|
||||
"-np", "--parallel",
|
||||
"-kvu", "--kv-unified", "-no-kvu", "--no-kv-unified",
|
||||
"--kv-unified-per-slot",
|
||||
"--cache-idle-slots", "--no-cache-idle-slots",
|
||||
}
|
||||
|
||||
// sideSlotArgEnv : les mêmes leviers posés par variable. La ligne de commande
|
||||
// les écraserait sans rien dire : un choix de l'utilisateur, on s'abstient.
|
||||
var sideSlotArgEnv = []string{"LLAMA_ARG_N_PARALLEL", "LLAMA_ARG_KV_UNIFIED", "LLAMA_ARG_KV_UNIFIED_PER_SLOT",
|
||||
"LLAMA_ARG_CACHE_IDLE_SLOTS", "LLAMA_ARG_CTX_SIZE"}
|
||||
|
||||
// probeSideSlotEnv complète ArgEnv avec sideSlotArgEnv, seulement si la clé est
|
||||
// posée : sans elle, rien n'est lu.
|
||||
func probeSideSlotEnv(cfg map[string]string, si *serveSysInfo) {
|
||||
if !sideSlotOn(cfg) {
|
||||
return
|
||||
}
|
||||
if si.ArgEnv == nil {
|
||||
si.ArgEnv = map[string]string{}
|
||||
}
|
||||
for _, k := range sideSlotArgEnv {
|
||||
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sideSlotPlan décide de SIDE_SLOT pour ce lancement. ctx > 0 : accepté, chaque
|
||||
// slot aura ctx jetons (le pool en fait le double) ; extraMiB est la VRAM de
|
||||
// plus, estimée. ctx == 0 : refusé (why dit pourquoi), ou clé absente (why vide)
|
||||
// — la ligne de commande est alors celle d'avant, à l'octet près.
|
||||
func sideSlotPlan(cfg map[string]string, extra []string, si serveSysInfo) (ctx int, extraMiB int64, why string) {
|
||||
if !sideSlotOn(cfg) {
|
||||
return 0, 0, ""
|
||||
}
|
||||
if isExternalConfig(cfg) {
|
||||
return 0, 0, "preset externe"
|
||||
}
|
||||
if p := strings.TrimSpace(cfg["PARALLEL"]); p != "" && p != "2" {
|
||||
return 0, 0, "PARALLEL=" + p + " (SIDE_SLOT ouvre exactement 2 slots)"
|
||||
}
|
||||
for _, f := range sideSlotConflictFlags {
|
||||
if hasAnyFlag(extra, f) {
|
||||
return 0, 0, f + " est réglé dans EXTRA_ARGS"
|
||||
}
|
||||
}
|
||||
for _, k := range sideSlotArgEnv {
|
||||
if si.ArgEnv[k] != "" {
|
||||
return 0, 0, k + " est posée"
|
||||
}
|
||||
}
|
||||
if !strings.Contains(si.Help, "--no-kv-unified") {
|
||||
return 0, 0, "ce moteur ne connaît pas --no-kv-unified"
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(cfg["CTX"]))
|
||||
if strings.TrimSpace(cfg["CTX"]) == "" {
|
||||
n, err = 32768, nil
|
||||
}
|
||||
if err != nil || n <= 0 {
|
||||
return 0, 0, "CTX doit être un nombre de jetons explicite"
|
||||
}
|
||||
extraMiB, why = sideSlotVRAM(cfg, extra, si, n)
|
||||
if why != "" {
|
||||
return 0, 0, why
|
||||
}
|
||||
return n, extraMiB, ""
|
||||
}
|
||||
|
||||
// sideSlotVRAM estime la VRAM du second slot (cache KV de ctx jetons et état
|
||||
// récurrent) et vérifie que modèle + deux états tiennent dans 90 % de la VRAM,
|
||||
// la même marge que cacheRAMAuto. Inconnu = refus : on ne double pas le cache
|
||||
// KV à l'aveugle, c'est exactement la panne de mémoire qu'on a déjà vécue.
|
||||
func sideSlotVRAM(cfg map[string]string, extra []string, si serveSysInfo, ctx int) (extraMiB int64, why string) {
|
||||
switch {
|
||||
case si.VRAMMiB <= 0:
|
||||
return 0, "VRAM NVIDIA inconnue (macOS, AMD, --device…)"
|
||||
case si.GGUF == nil:
|
||||
return 0, "métadonnées GGUF illisibles"
|
||||
case si.ModelBytes <= 0:
|
||||
return 0, "taille du modèle inconnue"
|
||||
}
|
||||
if r := cpuWeights(cfg, extra, si.ArgEnv, si.GGUF.BlockCount); r != "" {
|
||||
return 0, r
|
||||
}
|
||||
kt, vt, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
|
||||
perTok, rec := ggufStateBytes(*si.GGUF, kt, vt)
|
||||
if perTok <= 0 {
|
||||
return 0, "taille d'état inconnue"
|
||||
}
|
||||
state := perTok*float64(ctx) + float64(rec)
|
||||
if float64(si.ModelBytes)+2*state > 0.9*float64(si.VRAMMiB)*(1<<20) {
|
||||
return 0, fmt.Sprintf("VRAM insuffisante : modèle + 2 × %d Mio d'état dépassent 90 %% de %d Mio",
|
||||
int64(math.Ceil(state/(1<<20))), si.VRAMMiB)
|
||||
}
|
||||
return int64(math.Ceil(state / (1 << 20))), ""
|
||||
}
|
||||
|
||||
// sideSlotNote : ce que le lancement dit de SIDE_SLOT, accepté ou refusé.
|
||||
func sideSlotNote(ctx int, extraMiB int64, why string, nglForced bool) string {
|
||||
if ctx <= 0 {
|
||||
if why == "" {
|
||||
return ""
|
||||
}
|
||||
return "SIDE_SLOT refusé (" + why + ") : un seul slot, comme sans la clé"
|
||||
}
|
||||
n := fmt.Sprintf("SIDE_SLOT : 2 slots de %d jetons (-c %d, --no-kv-unified), ~%d Mio de VRAM en plus "+
|
||||
"pour le second (cache KV et état récurrent ; le brouillon MTP éventuel et les points de reprise en RAM hôte "+
|
||||
"doublent aussi)", ctx, 2*ctx, extraMiB)
|
||||
if nglForced {
|
||||
return n + " ; NGL est imposé : surveille la VRAM au premier long prompt"
|
||||
}
|
||||
return n + " ; le placement automatique sortira des couches du GPU s'il manque de place"
|
||||
}
|
||||
|
||||
// sideSlotPreview : l'avis de SIDE_SLOT pour l'éditeur de preset, calculé comme
|
||||
// au lancement, à l'aide du moteur près (supposée connaître --no-kv-unified :
|
||||
// un moteur trop ancien sera refusé au lancement, et le journal le dira).
|
||||
func sideSlotPreview(content string) (on bool, extraMiB int64, why string) {
|
||||
cfg := parseEnv(content)
|
||||
if !sideSlotOn(cfg) {
|
||||
return false, 0, ""
|
||||
}
|
||||
if isExternalConfig(cfg) {
|
||||
return false, 0, "preset externe"
|
||||
}
|
||||
extra := splitArgs(cfg["EXTRA_ARGS"])
|
||||
si := serveSysInfo{Help: "--no-kv-unified", ArgEnv: map[string]string{}}
|
||||
for _, k := range append(append(append(append([]string{}, serveArgEnv...), fidelityArgEnv...), cacheArgEnv...), sideSlotArgEnv...) {
|
||||
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
if m := strings.TrimSpace(cfg["MODEL"]); m != "" {
|
||||
if p, err := resolveServeModelPath(m); err == nil {
|
||||
si.Model = p
|
||||
}
|
||||
}
|
||||
if si.Model == "" {
|
||||
return false, 0, "modèle introuvable"
|
||||
}
|
||||
probeCacheRAM(cfg, extra, &si)
|
||||
ctx, mib, why := sideSlotPlan(cfg, extra, si)
|
||||
return ctx > 0, mib, why
|
||||
}
|
||||
|
||||
// nglForced : le preset impose-t-il un nombre de couches GPU ? Alors le
|
||||
// placement automatique ne tourne pas, et un second état de trop finit en
|
||||
// panne de mémoire plutôt qu'en couches sorties du GPU. 999 est la sentinelle
|
||||
// « toutes », traduite en auto sur un moteur récent (voir nglArgs).
|
||||
func nglForced(cfg map[string]string, extra []string) bool {
|
||||
v := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
|
||||
if v == "" {
|
||||
v = strings.TrimSpace(cfg["NGL"])
|
||||
if v == "999" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return v != "" && !strings.EqualFold(v, "auto")
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// helpSide : un moteur qui connaît le cache KV non unifié.
|
||||
const helpSide = helpCacheRAM + `-kvu, --kv-unified, -no-kvu, --no-kv-unified
|
||||
use single unified KV buffer shared across all sequences
|
||||
`
|
||||
|
||||
// sideCfg : le preset de denseGPU (0,25 Mio d'état par jeton) à 32k, avec ou
|
||||
// sans SIDE_SLOT.
|
||||
func sideCfg(kv ...string) map[string]string {
|
||||
cfg := map[string]string{"CTX": "32768", "NGL": "999"}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
cfg[kv[i]] = kv[i+1]
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func sideSI() serveSysInfo {
|
||||
si := denseGPU()
|
||||
si.Help = helpSide
|
||||
return si
|
||||
}
|
||||
|
||||
// Sans la clé (ou à off), la ligne de commande est celle d'avant, à l'octet
|
||||
// près, notes comprises.
|
||||
func TestSideSlotDefautIdentique(t *testing.T) {
|
||||
for _, v := range []string{"", "off", "0"} {
|
||||
cfg := sideCfg()
|
||||
if v != "" {
|
||||
cfg["SIDE_SLOT"] = v
|
||||
}
|
||||
got, _, notes := buildServeArgs(cfg, nil, "/bin/llama-server", sideSI())
|
||||
want, _, wantNotes := buildServeArgs(sideCfg(), nil, "/bin/llama-server", sideSI())
|
||||
if !reflect.DeepEqual(got, want) || !reflect.DeepEqual(notes, wantNotes) {
|
||||
t.Fatalf("SIDE_SLOT=%q change la ligne :\n%v\n%v", v, got, want)
|
||||
}
|
||||
if argValue(got, "--parallel") != "1" || argValue(got, "-c") != "32768" {
|
||||
t.Fatalf("défaut : %v", got)
|
||||
}
|
||||
for _, a := range got {
|
||||
if a == "--no-kv-unified" {
|
||||
t.Fatal("--no-kv-unified sans la clé")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Accepté : deux slots de CTX jetons chacun, flux KV séparés — le pool fait
|
||||
// le double, aucun slot ne peut déborder sur l'autre.
|
||||
func TestSideSlotAccepte(t *testing.T) {
|
||||
for _, cfg := range []map[string]string{sideCfg("SIDE_SLOT", "on"), sideCfg("SIDE_SLOT", "on", "PARALLEL", "2")} {
|
||||
args, _, notes := buildServeArgs(cfg, nil, "/bin/llama-server", sideSI())
|
||||
if argValue(args, "-c") != "65536" || argValue(args, "--parallel") != "2" {
|
||||
t.Fatalf("args = %v", args)
|
||||
}
|
||||
n := 0
|
||||
for i, a := range args {
|
||||
if a == "--no-kv-unified" {
|
||||
n++
|
||||
if args[i-1] != "2" || args[i-2] != "--parallel" {
|
||||
t.Fatalf("--no-kv-unified mal placé : %v", args)
|
||||
}
|
||||
}
|
||||
if a == "--kv-unified-per-slot" || a == "-kvu" || a == "--kv-unified" {
|
||||
t.Fatalf("drapeau inattendu %s", a)
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("--no-kv-unified × %d", n)
|
||||
}
|
||||
found := false
|
||||
for _, s := range notes {
|
||||
if strings.HasPrefix(s, "SIDE_SLOT : 2 slots de 32768 jetons") && strings.Contains(s, "~8192 Mio") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("note de VRAM absente : %q", notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refusé : la ligne est exactement celle sans la clé, plus une note qui dit
|
||||
// pourquoi.
|
||||
func TestSideSlotRefus(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
si func(*serveSysInfo)
|
||||
why string
|
||||
}{
|
||||
{name: "PARALLEL=3", cfg: sideCfg("SIDE_SLOT", "on", "PARALLEL", "3"), why: "PARALLEL=3"},
|
||||
{name: "-c dans EXTRA_ARGS", extra: []string{"-c", "16384"}, why: "-c "},
|
||||
{name: "--ctx-size=", extra: []string{"--ctx-size=16384"}, why: "--ctx-size"},
|
||||
{name: "-np", extra: []string{"-np", "4"}, why: "-np"},
|
||||
{name: "-kvu", extra: []string{"-kvu"}, why: "-kvu"},
|
||||
{name: "--no-kv-unified", extra: []string{"--no-kv-unified"}, why: "--no-kv-unified"},
|
||||
{name: "--kv-unified-per-slot", extra: []string{"--kv-unified-per-slot", "8192"}, why: "--kv-unified-per-slot"},
|
||||
{name: "--cache-idle-slots", extra: []string{"--cache-idle-slots"}, why: "--cache-idle-slots"},
|
||||
{name: "--no-cache-idle-slots", extra: []string{"--no-cache-idle-slots"}, why: "--no-cache-idle-slots"},
|
||||
{name: "variable LLAMA_ARG_N_PARALLEL", si: func(si *serveSysInfo) { si.ArgEnv["LLAMA_ARG_N_PARALLEL"] = "4" }, why: "LLAMA_ARG_N_PARALLEL"},
|
||||
{name: "variable LLAMA_ARG_KV_UNIFIED", si: func(si *serveSysInfo) { si.ArgEnv["LLAMA_ARG_KV_UNIFIED"] = "1" }, why: "LLAMA_ARG_KV_UNIFIED"},
|
||||
{name: "moteur sans --no-kv-unified", si: func(si *serveSysInfo) { si.Help = helpCacheRAM }, why: "--no-kv-unified"},
|
||||
{name: "CTX=0", cfg: sideCfg("SIDE_SLOT", "on", "CTX", "0"), why: "CTX"},
|
||||
{name: "VRAM trop juste à 64k", cfg: sideCfg("SIDE_SLOT", "on", "CTX", "65536"), why: "VRAM insuffisante"},
|
||||
{name: "VRAM inconnue", si: func(si *serveSysInfo) { si.VRAMMiB = 0 }, why: "VRAM"},
|
||||
{name: "GGUF illisible", si: func(si *serveSysInfo) { si.GGUF = nil }, why: "GGUF"},
|
||||
{name: "experts sur CPU", extra: []string{"--n-cpu-moe", "20"}, why: "CPU"},
|
||||
{name: "-ot sur CPU", extra: []string{"-ot", "exps=CPU"}, why: "CPU"},
|
||||
{name: "NGL partiel", cfg: sideCfg("SIDE_SLOT", "on", "NGL", "30"), why: "couches GPU"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
cfg := c.cfg
|
||||
if cfg == nil {
|
||||
cfg = sideCfg("SIDE_SLOT", "on")
|
||||
}
|
||||
si := sideSI()
|
||||
if c.si != nil {
|
||||
c.si(&si)
|
||||
}
|
||||
_, _, why := sideSlotPlan(cfg, c.extra, si)
|
||||
if !strings.Contains(why, c.why) {
|
||||
t.Errorf("%s : why = %q, attendu %q", c.name, why, c.why)
|
||||
}
|
||||
got, _, notes := buildServeArgs(cfg, c.extra, "/bin/llama-server", si)
|
||||
off := map[string]string{}
|
||||
for k, v := range cfg {
|
||||
if k != "SIDE_SLOT" {
|
||||
off[k] = v
|
||||
}
|
||||
}
|
||||
want, _, wantNotes := buildServeArgs(off, c.extra, "/bin/llama-server", si)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s : refus qui change la ligne\n%v\n%v", c.name, got, want)
|
||||
}
|
||||
if len(notes) != len(wantNotes)+1 {
|
||||
t.Errorf("%s : notes %q", c.name, notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,724 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// Décodage spéculatif géré par Loki : une tête MTP (prédiction de plusieurs
|
||||
// jetons) intégrée au modèle ou publiée à part, ou un petit modèle brouillon.
|
||||
//
|
||||
// SPEC=off (défaut) → rien, même avec MODEL_DRAFT
|
||||
// SPEC=auto → seulement si tout est réuni (voir specAutoBlocker)
|
||||
// SPEC=mtp → imposé, sans les garde-fous d'auto, avec un avertissement
|
||||
// MODEL_DRAFT=<nom> → la tête ou le brouillon, résolu comme MMPROJ
|
||||
// SPEC_N_MAX=<n> → --spec-draft-n-max (défaut du moteur : 3)
|
||||
// SPEC_SAMPLING=probabilistic → seulement avec SPEC=mtp ; greedy sinon
|
||||
// SPEC=ngram → n-grammes tirés du contexte (ngram-mod), sans brouillon
|
||||
// SPEC=mtp+ngram → les deux ; un n-gramme trouvé passe avant la tête MTP
|
||||
//
|
||||
// Rien n'y touche au modèle : chaque jeton émis est tiré par l'échantillonneur
|
||||
// du modèle cible, un jeton du brouillon n'est gardé que s'il coïncide
|
||||
// (common_sampler_sample_and_accept_n). Le prix est ailleurs : 1 à 2 Go de VRAM
|
||||
// en plus, un prefill parfois plus lent, et une fonction très récente qui peut
|
||||
// empêcher le moteur de démarrer. D'où l'opt-in, les garde-fous d'auto et le
|
||||
// jeton de tentative (specAutoVerdict) : un lancement automatique qui n'a
|
||||
// jamais répondu ne se retente pas avec la même configuration.
|
||||
//
|
||||
// Une vérification par lot n'emprunte pas les mêmes noyaux qu'un décodage jeton
|
||||
// par jeton : même distribution, mais pas le même texte au bit près — deux
|
||||
// candidats presque à égalité peuvent s'inverser à graine égale, comme entre
|
||||
// prefill et décodage. On compare donc des sessions rejouées, pas des diffs.
|
||||
|
||||
const (
|
||||
// specAutoMinBuild : premier build officiel où Loki ose l'auto (MTP serveur,
|
||||
// PR #22673 et suites). Build inconnu = non : un compilé maison ou un fork
|
||||
// peut connaître le drapeau sans le tenir.
|
||||
specAutoMinBuild = 11009
|
||||
specNMaxLimit = 64
|
||||
|
||||
// Bornes de ngram-mod, toujours écrites en clair : --spec-default pose les
|
||||
// mêmes aujourd'hui, mais son code porte un TODO qui annonce d'autres types —
|
||||
// une mise à jour du moteur changerait le réglage sans un mot. Un brouillon
|
||||
// part dès 24 jetons qui se répètent mot pour mot, et fait 48 à 64 jetons :
|
||||
// ce que recopie une boucle d'outils (chemins, diffs, arguments JSON).
|
||||
specNgramNMatch = 24
|
||||
specNgramNMin = 48
|
||||
specNgramNMax = 64
|
||||
|
||||
// ggmlOffloadMinBatchDefault : seuil par défaut du moteur (ggml-cuda.cu) à
|
||||
// partir duquel un lot recopie vers le GPU les poids restés en RAM.
|
||||
ggmlOffloadMinBatchDefault = 32
|
||||
)
|
||||
|
||||
// specArgEnv : les variables qui règlent la spéculation sans drapeau. Posées,
|
||||
// elles valent un réglage d'EXTRA_ARGS : Loki se tait.
|
||||
var specArgEnv = []string{"LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL", "LLAMA_ARG_SPEC_DRAFT_HF_REPO",
|
||||
"LLAMA_ARG_SPEC_DRAFT_N_MAX", "LLAMA_ARG_SPEC_DRAFT_SAMPLING"}
|
||||
|
||||
// specUserFlags : la spéculation réglée à la main. Les ajouter EN PLUS ferait
|
||||
// cumuler les types (--spec-type s'additionne, sans l'avertissement « specified
|
||||
// multiple times » des autres drapeaux) ou charger deux brouillons.
|
||||
var specUserFlags = []string{"--spec-type", "--spec_type", "-md", "--model-draft", "--spec-draft-model",
|
||||
"-hfd", "-hfrd", "--spec-draft-hf", "--hf-repo-draft", "--spec-default"}
|
||||
|
||||
// specUserPrefixes : des réglages de n-grammes à la main (--spec-ngram-mod-n-max,
|
||||
// --spec-ngram-simple-size-n…) disent la même chose qu'un --spec-type.
|
||||
var specUserPrefixes = []string{"--spec-ngram-", "--spec_ngram_"}
|
||||
|
||||
// hasFlagPrefix : un drapeau d'args commence-t-il par l'un de ces préfixes ?
|
||||
func hasFlagPrefix(args []string, prefixes ...string) string {
|
||||
for _, a := range args {
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(a, p) {
|
||||
name, _, _ := strings.Cut(a, "=")
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// probeSpec : variables du moteur, puis MODEL_DRAFT résolu comme MMPROJ (nom
|
||||
// simple cherché dans les dossiers déclarés, ou chemin absolu). Introuvable ne
|
||||
// bloque PAS le lancement, contrairement au projecteur : le brouillon n'est
|
||||
// qu'une accélération, le moteur démarre sans et la note dit pourquoi.
|
||||
func probeSpec(cfg map[string]string, si *serveSysInfo) {
|
||||
if si.ArgEnv == nil {
|
||||
si.ArgEnv = map[string]string{}
|
||||
}
|
||||
for _, k := range specArgEnv {
|
||||
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
d := strings.TrimSpace(cfg["MODEL_DRAFT"])
|
||||
if d == "" {
|
||||
return
|
||||
}
|
||||
p, err := resolveServeModelPath(d)
|
||||
if err == nil {
|
||||
_, err = os.Stat(p)
|
||||
}
|
||||
if err != nil {
|
||||
si.DraftErr = err.Error()
|
||||
return
|
||||
}
|
||||
si.Draft = p
|
||||
if g, err := ggufMeta(p); err == nil {
|
||||
si.DraftGGUF = &g
|
||||
}
|
||||
}
|
||||
|
||||
// specSidecarArch : têtes de spéculation qui ne sont pas des modèles autonomes
|
||||
// (llama.cpp : draft-eagle3, draft-dflash/dspark). Un -md vers elles avec
|
||||
// draft-simple ne démarrerait pas : leur type se règle à la main.
|
||||
func specSidecarArch(arch string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(arch)) {
|
||||
case "eagle3", "dflash":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// specMode lit SPEC : "off", "auto", "mtp", "ngram", "mtp+ngram", ou "" si
|
||||
// illisible.
|
||||
func specMode(cfg map[string]string) string {
|
||||
switch v := strings.ToLower(strings.TrimSpace(cfg["SPEC"])); v {
|
||||
case "", "off", "non", "no", "0":
|
||||
return "off"
|
||||
case "auto", "mtp", "ngram", "mtp+ngram":
|
||||
return v
|
||||
case "ngram+mtp":
|
||||
return "mtp+ngram"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// specUserSet : EXTRA_ARGS ou l'environnement règlent déjà la spéculation.
|
||||
func specUserSet(extra []string, argEnv map[string]string) bool {
|
||||
if hasAnyFlag(extra, specUserFlags...) || hasFlagPrefix(extra, specUserPrefixes...) != "" {
|
||||
return true
|
||||
}
|
||||
for _, k := range []string{"LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL", "LLAMA_ARG_SPEC_DRAFT_HF_REPO"} {
|
||||
if argEnv[k] != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// helpSupportsMTP : le moteur connaît le type draft-mtp ET le drapeau actuel
|
||||
// du nombre de jetons — un moteur qui n'a que l'ancien --draft-max est d'une
|
||||
// autre génération, où un MTP n'existe pas.
|
||||
func helpSupportsMTP(help string) bool {
|
||||
return strings.Contains(help, "draft-mtp") && strings.Contains(help, "--spec-draft-n-max")
|
||||
}
|
||||
|
||||
// ctxFixed : le contexte que verra le moteur (EXTRA_ARGS, sinon CTX, sinon le
|
||||
// défaut de buildServeArgs), et s'il est chiffré. Avec 0 ou une valeur
|
||||
// illisible, --fit a le droit de RÉDUIRE le contexte pour tenir.
|
||||
func ctxFixed(cfg map[string]string, extra []string) (string, bool) {
|
||||
c := flagValue(extra, "-c", "--ctx-size")
|
||||
if c == "" {
|
||||
c = strings.TrimSpace(cfg["CTX"])
|
||||
}
|
||||
if c == "" {
|
||||
c = "32768"
|
||||
}
|
||||
n, err := strconv.Atoi(c)
|
||||
return c, err == nil && n > 0
|
||||
}
|
||||
|
||||
// statefulSampler : un échantillonneur dont l'état avance à chaque tirage
|
||||
// (mirostat, adaptive-p). Le rejet probabiliste fait d'abord tirer le modèle
|
||||
// cible, puis peut émettre un autre jeton : ces échantillonneurs avanceraient
|
||||
// sur un jeton que la réponse ne contient pas. Le mode greedy, lui, reste exact.
|
||||
func statefulSampler(extra []string) string {
|
||||
for _, v := range flagValues(extra, "--mirostat") {
|
||||
if v = strings.TrimSpace(v); v != "" && v != "0" {
|
||||
return "--mirostat " + v
|
||||
}
|
||||
}
|
||||
for _, v := range flagValues(extra, "--samplers") {
|
||||
if strings.Contains(strings.ToLower(strings.ReplaceAll(v, "-", "_")), "adaptive_p") {
|
||||
return "--samplers adaptive-p"
|
||||
}
|
||||
}
|
||||
for _, v := range flagValues(extra, "--sampling-seq", "--sampler-seq") {
|
||||
if strings.Contains(v, "a") {
|
||||
return "--sampling-seq avec adaptive-p"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// specAutoBlocker dit pourquoi SPEC=auto ne s'active pas, ou vide. Chaque
|
||||
// garde répond à une panne précise :
|
||||
//
|
||||
// - qwen4exp : MTP fusionné il y a quelques jours, OOM signalés en multi-GPU ;
|
||||
// - placement manuel (couches fixées, -ts, -ot, experts sur CPU, -sm row,
|
||||
// --fit off, -dev) : seul --fit compte la VRAM de la tête MTP ;
|
||||
// - contexte non chiffré : fit aurait le droit de le RÉDUIRE pour faire de
|
||||
// la place — de l'information perdue pour le modèle ;
|
||||
// - projecteur vision : une VRAM déjà serrée par un second modèle ;
|
||||
// - build officiel inconnu ou trop ancien ;
|
||||
// - un essai précédent avec la même configuration n'a jamais répondu.
|
||||
func specAutoBlocker(cfg map[string]string, extra []string, si serveSysInfo) string {
|
||||
if si.GGUF != nil && strings.EqualFold(si.GGUF.Arch, "qwen4exp") {
|
||||
return "qwen4exp : MTP trop récent pour ce moteur, SPEC=mtp pour l'imposer"
|
||||
}
|
||||
if why := fitBlocker(cfg, extra, si); why != "" {
|
||||
return "placement manuel (" + why + ") — --fit ne compte pas la VRAM du brouillon"
|
||||
}
|
||||
if hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" {
|
||||
return "placement manuel (--device) — --fit ne compte pas la VRAM du brouillon"
|
||||
}
|
||||
if _, ok := ctxFixed(cfg, extra); !ok {
|
||||
return "contexte non chiffré — fit pourrait le réduire pour loger le brouillon"
|
||||
}
|
||||
if si.MMProj != "" || hasAnyFlag(extra, "--mmproj", "-mm") {
|
||||
return "projecteur vision chargé"
|
||||
}
|
||||
if si.EngineBuild < specAutoMinBuild {
|
||||
if si.EngineBuild <= 0 {
|
||||
return "build du moteur inconnu (compilé ou fork)"
|
||||
}
|
||||
return fmt.Sprintf("moteur b%d, b%d au moins", si.EngineBuild, specAutoMinBuild)
|
||||
}
|
||||
return si.SpecAutoBlocked
|
||||
}
|
||||
|
||||
// specArgs : drapeaux de décodage spéculatif que Loki ajoute, ce qu'il en dit,
|
||||
// et s'ils viennent de SPEC=auto (cmdServe pose alors le jeton de tentative).
|
||||
// Fonction pure ; cmdServe a déjà résolu MODEL_DRAFT et lu les GGUF.
|
||||
func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string, auto bool) {
|
||||
mode := specMode(cfg)
|
||||
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
|
||||
switch mode {
|
||||
case "":
|
||||
return nil, []string{"SPEC=" + cfg["SPEC"] + " illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif"}, false
|
||||
case "off":
|
||||
if draftKey != "" {
|
||||
return nil, []string{"MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)"}, false
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
if specUserSet(extra, si.ArgEnv) {
|
||||
return nil, []string{"décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré"}, false
|
||||
}
|
||||
skip := func(why string) ([]string, []string, bool) {
|
||||
return nil, []string{"SPEC=" + mode + " : " + why + " — sans décodage spéculatif"}, false
|
||||
}
|
||||
|
||||
// N-grammes : leurs garde-fous d'abord, ils valent pour les deux formes.
|
||||
if mode == "ngram" || mode == "mtp+ngram" {
|
||||
if why := ngramBlocker(cfg, extra, si, mode); why != "" {
|
||||
return skip(why)
|
||||
}
|
||||
}
|
||||
if mode == "ngram" {
|
||||
args, notes = ngramSpecArgs(cfg, extra, si, "")
|
||||
return args, notes, false
|
||||
}
|
||||
|
||||
src, mtp, why, quiet := specSource(mode, draftKey, si)
|
||||
if mode == "mtp+ngram" && !mtp {
|
||||
// Pas de tête MTP utilisable : la passer quand même ferait mourir le
|
||||
// moteur sur « failed to create MTP context », et boucler. Les n-grammes,
|
||||
// eux, n'ont besoin de rien.
|
||||
if why == "" {
|
||||
why = "MODEL_DRAFT n'est pas une tête MTP"
|
||||
}
|
||||
args, notes = ngramSpecArgs(cfg, extra, si, "SPEC=mtp+ngram : sans MTP ("+why+") → n-grammes seuls. ")
|
||||
return args, notes, false
|
||||
}
|
||||
if why != "" {
|
||||
return skip(why)
|
||||
}
|
||||
if quiet {
|
||||
return nil, nil, false
|
||||
}
|
||||
args = src
|
||||
if mode == "mtp+ngram" {
|
||||
// Une seule liste : --spec-type s'additionne, mais une valeur unique se
|
||||
// lit d'un coup d'œil dans le journal.
|
||||
args[len(args)-1] = "draft-mtp,ngram-mod"
|
||||
args = append(args, ngramModParams()...)
|
||||
}
|
||||
|
||||
label := "brouillon " + baseName(si.Draft)
|
||||
if mtp {
|
||||
label = "tête MTP"
|
||||
}
|
||||
if mode == "auto" {
|
||||
if why := specAutoBlocker(cfg, extra, si); why != "" {
|
||||
return skip(why)
|
||||
}
|
||||
auto = true
|
||||
notes = append(notes, "SPEC=auto → "+label+" : sortie inchangée (chaque jeton est vérifié par le modèle), "+
|
||||
"~1-2 Go de VRAM en plus. Mesure le prefill ; SPEC=off pour couper.")
|
||||
} else {
|
||||
note := "SPEC=" + mode + " → " + label + " imposé : sortie inchangée, ~1-2 Go de VRAM en plus"
|
||||
if mode == "mtp+ngram" {
|
||||
note += " ; n-grammes en plus (ngram-mod, prioritaires quand ils trouvent une répétition)"
|
||||
}
|
||||
if why := fitBlocker(cfg, extra, si); why != "" || hasAnyFlag(extra, "-dev", "--device") {
|
||||
if why == "" {
|
||||
why = "--device"
|
||||
}
|
||||
note += " ; placement manuel (" + why + ") : --fit ne compte pas le brouillon, " +
|
||||
"place-le avec -devd / -ngld si la VRAM manque"
|
||||
}
|
||||
if _, ok := ctxFixed(cfg, extra); !ok {
|
||||
note += " ; contexte non chiffré : fit pourrait le réduire pour loger le brouillon"
|
||||
}
|
||||
if si.GGUF != nil && strings.EqualFold(si.GGUF.Arch, "qwen4exp") {
|
||||
note += " ; MTP qwen4exp très récent"
|
||||
}
|
||||
notes = append(notes, note)
|
||||
if mode == "mtp+ngram" {
|
||||
notes = append(notes, ngramCostNotes(cfg, extra, si)...)
|
||||
}
|
||||
}
|
||||
|
||||
// Nombre de jetons anticipés : défaut du moteur (3) si la clé est vide.
|
||||
if v := strings.TrimSpace(cfg["SPEC_N_MAX"]); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
switch {
|
||||
case err != nil || n < 1 || n > specNMaxLimit:
|
||||
notes = append(notes, "SPEC_N_MAX="+v+" illisible (1 à 64 jetons) : défaut du moteur")
|
||||
case !strings.Contains(si.Help, "--spec-draft-n-max"):
|
||||
notes = append(notes, "ce moteur ne connaît pas --spec-draft-n-max : SPEC_N_MAX ignoré")
|
||||
case hasAnyFlag(extra, "--spec-draft-n-max") || si.ArgEnv["LLAMA_ARG_SPEC_DRAFT_N_MAX"] != "":
|
||||
default:
|
||||
args = append(args, "--spec-draft-n-max", strconv.Itoa(n))
|
||||
}
|
||||
}
|
||||
|
||||
// Tirage du brouillon. greedy est exact quelle que soit la chaîne
|
||||
// d'échantillonnage : on le fixe dès que le moteur connaît le drapeau, pour
|
||||
// qu'un changement de défaut en amont ne change rien ici.
|
||||
sampling := "greedy"
|
||||
switch v := strings.ToLower(strings.TrimSpace(cfg["SPEC_SAMPLING"])); v {
|
||||
case "", "greedy":
|
||||
case "probabilistic":
|
||||
switch {
|
||||
case mode == "auto":
|
||||
notes = append(notes, "SPEC_SAMPLING=probabilistic ignoré avec SPEC=auto (SPEC=mtp pour le choisir) : greedy")
|
||||
case statefulSampler(extra) != "":
|
||||
notes = append(notes, "SPEC_SAMPLING=probabilistic refusé avec "+statefulSampler(extra)+
|
||||
" (cet échantillonneur avancerait sur des jetons rejetés) : greedy")
|
||||
case !strings.Contains(si.Help, "probabilistic"):
|
||||
notes = append(notes, "ce moteur ne connaît pas --spec-draft-sampling probabilistic : greedy")
|
||||
default:
|
||||
sampling = v
|
||||
}
|
||||
default:
|
||||
notes = append(notes, "SPEC_SAMPLING="+v+" illisible (greedy ou probabilistic) : greedy")
|
||||
}
|
||||
if strings.Contains(si.Help, "--spec-draft-sampling") && !hasAnyFlag(extra, "--spec-draft-sampling") &&
|
||||
si.ArgEnv["LLAMA_ARG_SPEC_DRAFT_SAMPLING"] == "" {
|
||||
args = append(args, "--spec-draft-sampling", sampling)
|
||||
}
|
||||
return args, notes, auto
|
||||
}
|
||||
|
||||
// specSource : d'où vient le brouillon de SPEC=auto|mtp|mtp+ngram. MODEL_DRAFT
|
||||
// s'il est posé, sinon la tête MTP du modèle ; dans les deux cas on regarde les
|
||||
// TENSEURS, comme llama.cpp : une clé nextn_predict_layers seule ne prouve pas
|
||||
// que la tête est dans le fichier. why non vide = aucun brouillon ; quiet =
|
||||
// rien à dire (auto sur un modèle sans MTP).
|
||||
func specSource(mode, draftKey string, si serveSysInfo) (args []string, mtp bool, why string, quiet bool) {
|
||||
switch {
|
||||
case draftKey != "":
|
||||
switch {
|
||||
case si.Draft == "":
|
||||
why := "MODEL_DRAFT=" + draftKey + " introuvable"
|
||||
if si.DraftErr != "" {
|
||||
why += " (" + si.DraftErr + ")"
|
||||
}
|
||||
return nil, false, why, false
|
||||
case si.DraftGGUF == nil:
|
||||
return nil, false, "MODEL_DRAFT illisible (GGUF incomplet ou en cours de téléchargement ?)", false
|
||||
case si.DraftGGUF.HasNextNTensor:
|
||||
if !helpSupportsMTP(si.Help) {
|
||||
return nil, false, "ce moteur ne connaît pas draft-mtp", false
|
||||
}
|
||||
// Type explicite : llama.cpp ne le devine que sur la première tranche.
|
||||
return []string{"-md", si.Draft, "--spec-type", "draft-mtp"}, true, "", false
|
||||
case specSidecarArch(si.DraftGGUF.Arch):
|
||||
return nil, false, "MODEL_DRAFT est une tête " + si.DraftGGUF.Arch +
|
||||
", pas un modèle brouillon : règle -md et --spec-type dans EXTRA_ARGS", false
|
||||
case mode == "mtp+ngram":
|
||||
return nil, false, "", false // un modèle brouillon n'est pas une tête MTP
|
||||
default:
|
||||
if !strings.Contains(si.Help, "--spec-type") || !strings.Contains(si.Help, "draft-simple") {
|
||||
return nil, false, "ce moteur ne connaît pas --spec-type draft-simple", false
|
||||
}
|
||||
// Sans type, un brouillon qui n'est pas une tête MTP serait chargé en
|
||||
// VRAM puis jamais utilisé.
|
||||
return []string{"-md", si.Draft, "--spec-type", "draft-simple"}, false, "", false
|
||||
}
|
||||
case si.GGUF == nil:
|
||||
return nil, false, "métadonnées du modèle illisibles", false
|
||||
case !si.GGUF.HasNextNTensor:
|
||||
if mode != "auto" || si.GGUF.NextN > 0 {
|
||||
return nil, false, "pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)", false
|
||||
}
|
||||
return nil, false, "", true
|
||||
case !helpSupportsMTP(si.Help):
|
||||
return nil, false, "ce moteur ne connaît pas draft-mtp", false
|
||||
}
|
||||
return []string{"--spec-type", "draft-mtp"}, true, "", false
|
||||
}
|
||||
|
||||
// ngramModParams : les trois bornes de ngram-mod, toujours explicites.
|
||||
func ngramModParams() []string {
|
||||
return []string{
|
||||
"--spec-ngram-mod-n-match", strconv.Itoa(specNgramNMatch),
|
||||
"--spec-ngram-mod-n-min", strconv.Itoa(specNgramNMin),
|
||||
"--spec-ngram-mod-n-max", strconv.Itoa(specNgramNMax),
|
||||
}
|
||||
}
|
||||
|
||||
// ngramBlocker dit pourquoi SPEC=ngram|mtp+ngram ne s'active pas, ou vide.
|
||||
//
|
||||
// - Le moteur doit connaître --spec-ngram-mod-n-match, pas seulement le mot
|
||||
// « ngram-mod » : les moteurs d'avant le renommage ont encore le type dans
|
||||
// leur liste mais refusent les drapeaux actuels, et meurent au démarrage.
|
||||
// - SPEC=ngram n'a pas de brouillon : un --spec-draft-* d'EXTRA_ARGS veut dire
|
||||
// qu'on règle un brouillon à la main, on ne mélange pas.
|
||||
// - Poids en RAM (experts MoE sur CPU…) : llama.cpp recopie vers le GPU les
|
||||
// poids d'un lot dès GGML_OP_OFFLOAD_MIN_BATCH jetons (32 par défaut). Un
|
||||
// lot de vérification de n_max+1 jetons au-dessus du seuil ferait passer
|
||||
// les experts de chaque couche par le PCIe — voire par le disque si le
|
||||
// modèle mappé dépasse la RAM — à chaque brouillon. Refusé tant que le seuil
|
||||
// n'est pas relevé au-dessus du lot.
|
||||
func ngramBlocker(cfg map[string]string, extra []string, si serveSysInfo, mode string) string {
|
||||
if !strings.Contains(si.Help, "--spec-ngram-mod-n-match") {
|
||||
return "ce moteur ne connaît pas --spec-ngram-mod-n-match"
|
||||
}
|
||||
if mode == "ngram" {
|
||||
if f := hasFlagPrefix(extra, "--spec-draft-", "--spec_draft_"); f != "" {
|
||||
return f + " dans EXTRA_ARGS (brouillon réglé à la main)"
|
||||
}
|
||||
}
|
||||
blocks := 0
|
||||
if si.GGUF != nil {
|
||||
blocks = si.GGUF.BlockCount
|
||||
}
|
||||
if r := cpuWeights(cfg, extra, si.ArgEnv, blocks); r != "" {
|
||||
if thr := opOffloadThreshold(cfg, si); specNgramNMax+1 >= thr {
|
||||
return fmt.Sprintf("%s : un lot de vérification de %d jetons atteint GGML_OP_OFFLOAD_MIN_BATCH (%d) et "+
|
||||
"recopierait ces poids vers le GPU à chaque brouillon — OP_OFFLOAD_MIN_BATCH=128 pour l'essayer, à mesurer",
|
||||
r, specNgramNMax+1, thr)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// opOffloadThreshold : le seuil GGML_OP_OFFLOAD_MIN_BATCH que verra le moteur —
|
||||
// celui de l'environnement, sinon celui d'OP_OFFLOAD_MIN_BATCH, sinon le défaut.
|
||||
func opOffloadThreshold(cfg map[string]string, si serveSysInfo) int {
|
||||
v := strings.TrimSpace(si.UserEnv["GGML_OP_OFFLOAD_MIN_BATCH"])
|
||||
if v == "" {
|
||||
v, _ = opOffloadMinBatchEnv(cfg, si)
|
||||
}
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
return ggmlOffloadMinBatchDefault
|
||||
}
|
||||
|
||||
// ngramSpecArgs : SPEC=ngram, ou le repli de mtp+ngram sans tête MTP (lead dit
|
||||
// pourquoi). Pas de --spec-draft-* : il n'y a pas de brouillon.
|
||||
func ngramSpecArgs(cfg map[string]string, extra []string, si serveSysInfo, lead string) (args, notes []string) {
|
||||
args = append([]string{"--spec-type", "ngram-mod"}, ngramModParams()...)
|
||||
notes = append(notes, lead+fmt.Sprintf("SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de %d à %d jetons) : "+
|
||||
"distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer "+
|
||||
"(acceptation par nature dans /api/perf/summary)", specNgramNMin, specNgramNMax))
|
||||
if lead == "" {
|
||||
// --spec-type explicite : llama.cpp ne choisit plus de lui-même la tête MTP.
|
||||
switch {
|
||||
case strings.TrimSpace(cfg["MODEL_DRAFT"]) != "":
|
||||
notes = append(notes, "MODEL_DRAFT ignoré avec SPEC=ngram : SPEC=mtp+ngram pour les deux")
|
||||
case si.GGUF != nil && si.GGUF.HasNextNTensor:
|
||||
notes = append(notes, "tête MTP du modèle inutilisée avec SPEC=ngram : SPEC=mtp+ngram pour les deux")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(cfg["SPEC_N_MAX"]) != "" || strings.TrimSpace(cfg["SPEC_SAMPLING"]) != "" {
|
||||
notes = append(notes, "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler")
|
||||
}
|
||||
return args, append(notes, ngramCostNotes(cfg, extra, si)...)
|
||||
}
|
||||
|
||||
// ngramCostNotes : ce que les longs brouillons coûtent, là où ça se voit.
|
||||
func ngramCostNotes(cfg map[string]string, extra []string, si serveSysInfo) []string {
|
||||
var notes []string
|
||||
if si.GGUF != nil && si.GGUF.Hybrid {
|
||||
notes = append(notes, "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM "+
|
||||
"hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit")
|
||||
}
|
||||
blocks := 0
|
||||
if si.GGUF != nil {
|
||||
blocks = si.GGUF.BlockCount
|
||||
}
|
||||
if cpuWeights(cfg, extra, si.ArgEnv, blocks) != "" && si.ModelBytes > 0 && si.RAMMiB > 0 &&
|
||||
si.ModelBytes > si.RAMMiB<<20 {
|
||||
notes = append(notes, "modèle plus gros que la RAM, poids sur CPU : un lot de vérification lit la plupart des "+
|
||||
"experts de chaque couche — surveille les lectures disque, pas seulement les jetons/s")
|
||||
}
|
||||
if nglForced(cfg, extra) {
|
||||
notes = append(notes, fmt.Sprintf("NGL imposé (--fit inactif) : les logits de %d positions par slot prennent "+
|
||||
"quelques dizaines de Mio de VRAM en plus", specNgramNMax+1))
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
// --- Jeton de tentative ------------------------------------------------------
|
||||
//
|
||||
// Un essai automatique qui fait tomber le moteur ne doit pas se rejouer : sous
|
||||
// systemd il redémarrerait en boucle en rechargeant 15 Go à chaque fois. cmdServe
|
||||
// ne peut pas constater l'échec lui-même (sous Unix il DEVIENT llama-server).
|
||||
// Il pose donc un jeton avant de lancer ; le process web l'efface dès que le
|
||||
// moteur répond. Au lancement suivant, un jeton encore là pour la même
|
||||
// configuration, le même moteur et le même build veut dire « jamais répondu » :
|
||||
// l'auto est coupé pour cette combinaison, et on le dit. Une mise à jour du
|
||||
// moteur ou un preset modifié retente. Sans process web (CLI seule), le jeton
|
||||
// n'est jamais effacé : on retombe sur off, le côté sûr.
|
||||
|
||||
const (
|
||||
specAttemptKey = "spec_auto_attempt"
|
||||
specFailedKey = "spec_auto_failed"
|
||||
specFailedMax = 32
|
||||
)
|
||||
|
||||
// specAutoMark identifie une combinaison preset × moteur.
|
||||
type specAutoMark struct {
|
||||
FP string `json:"fp"`
|
||||
Bin string `json:"bin"`
|
||||
Build int `json:"build"`
|
||||
}
|
||||
|
||||
func (m specAutoMark) key() string { return fmt.Sprintf("%s|%s|%d", m.FP, m.Bin, m.Build) }
|
||||
|
||||
// specAutoVerdict : pure. Renvoie la raison de couper l'auto (vide = permis)
|
||||
// et s'il faut inscrire cur parmi les échecs.
|
||||
func specAutoVerdict(cur specAutoMark, attempt *specAutoMark, failed map[string]string) (why string, record bool) {
|
||||
if w := failed[cur.key()]; w != "" {
|
||||
return w, false
|
||||
}
|
||||
if attempt != nil && attempt.key() == cur.key() {
|
||||
return "le dernier lancement avec cette configuration n'a jamais répondu", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// specAutoPeek lit l'état sans rien changer : la raison de couper l'auto (vide
|
||||
// = permis) et s'il faudra inscrire l'échec. Rien n'est consommé avant que le
|
||||
// port soit libre (specAutoSettle) : un second « loki serve », refusé parce que
|
||||
// le premier charge encore, ne doit pas prendre le jeton de celui-ci pour un
|
||||
// échec.
|
||||
func specAutoPeek(cur specAutoMark) (why string, record bool) {
|
||||
_ = view(bkState, func(b *bolt.Bucket) error {
|
||||
attempt, failed := readSpecAuto(b)
|
||||
why, record = specAutoVerdict(cur, attempt, failed)
|
||||
return nil
|
||||
})
|
||||
return why, record
|
||||
}
|
||||
|
||||
// specAutoSettle range, port libre, juste avant de lancer le moteur : l'ancien
|
||||
// jeton est consommé, l'échec inscrit s'il y a lieu (record, why), et un
|
||||
// nouveau jeton posé si ce lancement ajoute des drapeaux automatiques.
|
||||
func specAutoSettle(cur specAutoMark, why string, record, attempt bool) {
|
||||
_ = update(bkState, func(b *bolt.Bucket) error {
|
||||
_, failed := readSpecAuto(b)
|
||||
_ = b.Delete([]byte(specAttemptKey))
|
||||
if record {
|
||||
if err := putFailed(b, failed, cur.key(), why); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !attempt {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(cur)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put([]byte(specAttemptKey), raw)
|
||||
})
|
||||
}
|
||||
|
||||
// readSpecAuto : le jeton en cours (nil = aucun) et les échecs inscrits.
|
||||
func readSpecAuto(b *bolt.Bucket) (attempt *specAutoMark, failed map[string]string) {
|
||||
if raw := b.Get([]byte(specAttemptKey)); raw != nil {
|
||||
var a specAutoMark
|
||||
if json.Unmarshal(raw, &a) == nil {
|
||||
attempt = &a
|
||||
}
|
||||
}
|
||||
failed = map[string]string{}
|
||||
if raw := b.Get([]byte(specFailedKey)); raw != nil {
|
||||
_ = json.Unmarshal(raw, &failed)
|
||||
}
|
||||
return attempt, failed
|
||||
}
|
||||
|
||||
func putFailed(b *bolt.Bucket, failed map[string]string, key, why string) error {
|
||||
if len(failed) >= specFailedMax {
|
||||
failed = map[string]string{}
|
||||
}
|
||||
failed[key] = why
|
||||
raw, err := json.Marshal(failed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put([]byte(specFailedKey), raw)
|
||||
}
|
||||
|
||||
// --- Côté process web --------------------------------------------------------
|
||||
|
||||
var (
|
||||
specWatchOnce sync.Once
|
||||
offloadedRe = regexp.MustCompile(`offloaded (\d+)/(\d+) layers to GPU`)
|
||||
)
|
||||
|
||||
// startSpecAttemptWatch : une goroutine qui efface le jeton dès que le moteur
|
||||
// répond, sans attendre qu'une page interroge /api/status.
|
||||
func startSpecAttemptWatch() {
|
||||
specWatchOnce.Do(func() {
|
||||
go func() {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
specAttemptTick()
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
func specAttemptTick() {
|
||||
var a specAutoMark
|
||||
if !getJSON(bkState, specAttemptKey, &a) {
|
||||
return
|
||||
}
|
||||
if externalActive() {
|
||||
specAttemptClear(a, "") // plus de moteur local à attendre
|
||||
return
|
||||
}
|
||||
if !healthCheck() {
|
||||
return
|
||||
}
|
||||
// Répondu. Mais avec -c fixé, fit ne réduit pas le contexte : s'il manque
|
||||
// la place du brouillon, il descend des couches en RAM — le moteur tourne,
|
||||
// plus lentement que sans MTP. Ça compte aussi comme un échec de l'auto.
|
||||
why := ""
|
||||
if n, m := lastOffload(serviceLogTail(600)); m > 0 && n < m {
|
||||
why = fmt.Sprintf("seulement %d/%d couches sur GPU avec le brouillon : fit a déplacé des couches en RAM", n, m)
|
||||
fmt.Println("[loki] SPEC=auto : " + why + " — coupé au prochain démarrage (SPEC=mtp pour l'imposer)")
|
||||
}
|
||||
specAttemptClear(a, why)
|
||||
}
|
||||
|
||||
// specAttemptClear efface le jeton a — et lui seul : relu sous verrou, car un
|
||||
// « loki serve » a pu entre-temps poser celui d'un AUTRE lancement, qui n'a pas
|
||||
// encore répondu. why non vide inscrit l'échec de a.
|
||||
func specAttemptClear(a specAutoMark, why string) {
|
||||
_ = update(bkState, func(b *bolt.Bucket) error {
|
||||
cur, failed := readSpecAuto(b)
|
||||
if cur == nil || cur.key() != a.key() {
|
||||
return nil
|
||||
}
|
||||
_ = b.Delete([]byte(specAttemptKey))
|
||||
if why == "" {
|
||||
return nil
|
||||
}
|
||||
return putFailed(b, failed, a.key(), why)
|
||||
})
|
||||
}
|
||||
|
||||
// lastOffload lit les lignes « offloaded N/M layers to GPU » du DERNIER
|
||||
// chargement du journal, repéré à la ligne du serveur « loading model
|
||||
// '<chemin>' ». Pas à « load_model » (le serveur écrit encore « load_model:
|
||||
// initializing… » APRÈS le chargement) ni à « loading model tensors » (écrit
|
||||
// aussi pour le brouillon, qui masquerait le modèle). Un brouillon chargé à
|
||||
// part a sa propre ligne : la pire des deux compte. 0, 0 = introuvable : on ne
|
||||
// conclut rien.
|
||||
func lastOffload(log string) (n, m int) {
|
||||
lines := strings.Split(log, "\n")
|
||||
start := -1
|
||||
for i, l := range lines {
|
||||
if strings.Contains(l, "loading model '") {
|
||||
start = i
|
||||
}
|
||||
}
|
||||
if start < 0 {
|
||||
return 0, 0
|
||||
}
|
||||
for _, l := range lines[start:] {
|
||||
s := offloadedRe.FindStringSubmatch(l)
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
a, _ := strconv.Atoi(s[1])
|
||||
b, _ := strconv.Atoi(s[2])
|
||||
if m == 0 || a < b {
|
||||
n, m = a, b
|
||||
}
|
||||
if a < b {
|
||||
break
|
||||
}
|
||||
}
|
||||
return n, m
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const helpSpec = helpRecent + `--spec-draft-n-max N number of tokens to draft for speculative decoding (default: 3)
|
||||
--spec-draft-sampling {greedy,probabilistic}
|
||||
how the draft is sampled (default: greedy)
|
||||
--spec-type [none,ngram-cache,ngram-simple,ngram-map-k,ngram-map-k4v,ngram-mod,draft-simple,draft-eagle3,draft-mtp,draft-dflash,draft-dspark]
|
||||
comma-separated list of types of speculative decoding to use (default: none)
|
||||
`
|
||||
|
||||
// helpSpecOld : un moteur qui connaît --spec-type mais pas encore le MTP.
|
||||
const helpSpecOld = helpRecent + `--spec-type [none,ngram-cache,ngram-simple,draft-simple]
|
||||
`
|
||||
|
||||
// mtpReady : un Qwen3.6 27B avec sa tête MTP dans le fichier, placé par --fit,
|
||||
// sur un moteur officiel récent. Le cas où SPEC=auto a le droit d'agir.
|
||||
func mtpReady() serveSysInfo {
|
||||
return serveSysInfo{
|
||||
Help: helpSpec,
|
||||
Model: "/models/Qwen3.6-27B-MTP.gguf",
|
||||
GGUF: &GGUFInfo{Arch: "qwen35", BlockCount: 65, NextN: 1, HasNextNTensor: true, Hybrid: true},
|
||||
ArgEnv: map[string]string{},
|
||||
EngineBuild: 11351,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecArgs(t *testing.T) {
|
||||
mtp := []string{"--spec-type", "draft-mtp", "--spec-draft-sampling", "greedy"}
|
||||
auto := map[string]string{"SPEC": "auto"}
|
||||
forced := map[string]string{"SPEC": "mtp"}
|
||||
with := func(base map[string]string, kv ...string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for k, v := range base {
|
||||
m[k] = v
|
||||
}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
m[kv[i]] = kv[i+1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
si func(*serveSysInfo)
|
||||
want []string
|
||||
wantAuto bool
|
||||
notes int
|
||||
}{
|
||||
{name: "SPEC absent : rien, même sur un modèle MTP (opt-in)", cfg: map[string]string{}},
|
||||
{name: "SPEC=off : rien", cfg: map[string]string{"SPEC": "off"}},
|
||||
{name: "SPEC=off avec MODEL_DRAFT : rien, et on le dit",
|
||||
cfg: map[string]string{"SPEC": "off", "MODEL_DRAFT": "mtp-Qwen3.6-27B.gguf"}, notes: 1},
|
||||
{name: "SPEC illisible : rien, et on le dit", cfg: map[string]string{"SPEC": "turbo"}, notes: 1},
|
||||
{name: "auto, tête MTP dans le fichier, --fit actif : draft-mtp", cfg: auto, want: mtp, wantAuto: true, notes: 1},
|
||||
{name: "auto, clé nextn sans tenseur (tête publiée à part) : rien",
|
||||
cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.GGUF.HasNextNTensor = false }},
|
||||
{name: "auto, modèle sans MTP : rien, sans bruit",
|
||||
cfg: auto, si: func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "llama", BlockCount: 32} }},
|
||||
{name: "auto, métadonnées illisibles : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.GGUF = nil }},
|
||||
{name: "--spec-type dans EXTRA_ARGS : Loki se tait", cfg: auto, extra: []string{"--spec-type", "ngram-mod"}, notes: 1},
|
||||
{name: "--model-draft=… dans EXTRA_ARGS : Loki se tait", cfg: auto, extra: []string{"--model-draft=d.gguf"}, notes: 1},
|
||||
{name: "-hfd dans EXTRA_ARGS : Loki se tait", cfg: auto, extra: []string{"-hfd", "u/r"}, notes: 1},
|
||||
{name: "--spec-default dans EXTRA_ARGS : Loki se tait", cfg: auto, extra: []string{"--spec-default"}, notes: 1},
|
||||
{name: "LLAMA_ARG_SPEC_TYPE posée : Loki se tait", cfg: auto, notes: 1,
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SPEC_TYPE"] = "draft-mtp" }},
|
||||
{name: "auto, --tensor-split : rien", cfg: auto, extra: []string{"--tensor-split", "0.965,0.035"}, notes: 1},
|
||||
{name: "auto, -ts=… : rien", cfg: auto, extra: []string{"-ts=1,1"}, notes: 1},
|
||||
{name: "auto, NGL chiffré : rien", cfg: with(auto, "NGL", "40"), notes: 1},
|
||||
{name: "auto, -ngl 99 dans EXTRA_ARGS : rien", cfg: auto, extra: []string{"-ngl", "99"}, notes: 1},
|
||||
{name: "auto, -ot : rien", cfg: auto, extra: []string{"-ot", "exps=CPU"}, notes: 1},
|
||||
{name: "auto, --n-cpu-moe : rien", cfg: auto, extra: []string{"--n-cpu-moe", "20"}, notes: 1},
|
||||
{name: "auto, -cmoe : rien", cfg: auto, extra: []string{"-cmoe"}, notes: 1},
|
||||
{name: "auto, -sm row : rien", cfg: auto, extra: []string{"-sm", "row"}, notes: 1},
|
||||
{name: "auto, -fit off : rien", cfg: auto, extra: []string{"-fit", "off"}, notes: 1},
|
||||
{name: "auto, -dev : rien", cfg: auto, extra: []string{"-dev", "CUDA0"}, notes: 1},
|
||||
{name: "auto, LLAMA_ARG_DEVICE : rien", cfg: auto, notes: 1,
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_DEVICE"] = "CUDA0" }},
|
||||
{name: "auto, CTX=0 (fit pourrait réduire le contexte) : rien", cfg: with(auto, "CTX", "0"), notes: 1},
|
||||
{name: "auto, projecteur vision : rien", cfg: auto, notes: 1,
|
||||
si: func(s *serveSysInfo) { s.MMProj = "/models/mmproj-F16.gguf" }},
|
||||
{name: "auto, build inconnu : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.EngineBuild = 0 }},
|
||||
{name: "auto, moteur trop ancien : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.EngineBuild = 10900 }},
|
||||
{name: "auto, qwen4exp : jamais d'office", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.GGUF.Arch = "qwen4exp" }},
|
||||
{name: "auto, essai précédent sans réponse : rien", cfg: auto, notes: 1,
|
||||
si: func(s *serveSysInfo) {
|
||||
s.SpecAutoBlocked = "le dernier lancement avec cette configuration n'a jamais répondu"
|
||||
}},
|
||||
{name: "auto, moteur sans draft-mtp : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpecOld }},
|
||||
{name: "auto, aide illisible : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.Help = "" }},
|
||||
{name: "mtp imposé malgré -ts : drapeaux, avec l'avertissement", cfg: forced,
|
||||
extra: []string{"-ts", "0.965,0.035"}, want: mtp, notes: 1},
|
||||
{name: "mtp imposé sur qwen4exp, build inconnu", cfg: forced, want: mtp, notes: 1,
|
||||
si: func(s *serveSysInfo) { s.GGUF.Arch = "qwen4exp"; s.EngineBuild = 0 }},
|
||||
{name: "mtp imposé, moteur sans draft-mtp : rien", cfg: forced, notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpecOld }},
|
||||
{name: "MODEL_DRAFT tête MTP à part : -md chemin absolu + draft-mtp",
|
||||
cfg: with(auto, "MODEL_DRAFT", "mtp-Qwen3.6-27B-Q8_0.gguf"), wantAuto: true, notes: 1,
|
||||
want: []string{"-md", "/models/mtp-Qwen3.6-27B-Q8_0.gguf", "--spec-type", "draft-mtp", "--spec-draft-sampling", "greedy"},
|
||||
si: func(s *serveSysInfo) {
|
||||
s.GGUF.HasNextNTensor = false
|
||||
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
|
||||
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
|
||||
}},
|
||||
{name: "MODEL_DRAFT petit modèle : -md + draft-simple, sinon chargé pour rien",
|
||||
cfg: with(forced, "MODEL_DRAFT", "Qwen3-0.6B.gguf"), notes: 1,
|
||||
want: []string{"-md", "/models/Qwen3-0.6B.gguf", "--spec-type", "draft-simple", "--spec-draft-sampling", "greedy"},
|
||||
si: func(s *serveSysInfo) {
|
||||
s.Draft = "/models/Qwen3-0.6B.gguf"
|
||||
s.DraftGGUF = &GGUFInfo{Arch: "qwen3", BlockCount: 28}
|
||||
}},
|
||||
{name: "MODEL_DRAFT tête dFlash : pas de draft-simple, réglage à la main",
|
||||
cfg: with(forced, "MODEL_DRAFT", "dflash-Qwen3.6.gguf"), notes: 1,
|
||||
si: func(s *serveSysInfo) {
|
||||
s.Draft = "/models/dflash-Qwen3.6.gguf"
|
||||
s.DraftGGUF = &GGUFInfo{Arch: "dflash", BlockCount: 5}
|
||||
}},
|
||||
{name: "MODEL_DRAFT tête EAGLE-3 : idem",
|
||||
cfg: with(auto, "MODEL_DRAFT", "eagle3.gguf"), notes: 1,
|
||||
si: func(s *serveSysInfo) {
|
||||
s.Draft = "/models/eagle3.gguf"
|
||||
s.DraftGGUF = &GGUFInfo{Arch: "eagle3", BlockCount: 1}
|
||||
}},
|
||||
{name: "MODEL_DRAFT introuvable : moteur lancé sans, pas d'erreur",
|
||||
cfg: with(auto, "MODEL_DRAFT", "absent.gguf"), notes: 1,
|
||||
si: func(s *serveSysInfo) { s.DraftErr = "fichier introuvable" }},
|
||||
{name: "MODEL_DRAFT illisible : rien", cfg: with(auto, "MODEL_DRAFT", "mtp-x.gguf"), notes: 1,
|
||||
si: func(s *serveSysInfo) { s.Draft = "/models/mtp-x.gguf" }},
|
||||
{name: "SPEC_N_MAX=2", cfg: with(auto, "SPEC_N_MAX", "2"), wantAuto: true, notes: 1,
|
||||
want: []string{"--spec-type", "draft-mtp", "--spec-draft-n-max", "2", "--spec-draft-sampling", "greedy"}},
|
||||
{name: "SPEC_N_MAX illisible : défaut du moteur", cfg: with(auto, "SPEC_N_MAX", "beaucoup"), want: mtp, wantAuto: true, notes: 2},
|
||||
{name: "--spec-draft-n-max dans EXTRA_ARGS : SPEC_N_MAX se tait, MTP reste",
|
||||
cfg: with(auto, "SPEC_N_MAX", "2"), extra: []string{"--spec-draft-n-max", "4"}, want: mtp, wantAuto: true, notes: 1},
|
||||
{name: "probabilistic jamais avec auto",
|
||||
cfg: with(auto, "SPEC_SAMPLING", "probabilistic"), want: mtp, wantAuto: true, notes: 2},
|
||||
{name: "probabilistic avec mtp imposé", cfg: with(forced, "SPEC_SAMPLING", "probabilistic"), notes: 1,
|
||||
want: []string{"--spec-type", "draft-mtp", "--spec-draft-sampling", "probabilistic"}},
|
||||
{name: "probabilistic refusé avec mirostat", cfg: with(forced, "SPEC_SAMPLING", "probabilistic"),
|
||||
extra: []string{"--mirostat", "2"}, want: mtp, notes: 2},
|
||||
{name: "probabilistic refusé avec adaptive-p", cfg: with(forced, "SPEC_SAMPLING", "probabilistic"),
|
||||
extra: []string{"--samplers", "top_k;adaptive_p"}, want: mtp, notes: 2},
|
||||
{name: "--spec-draft-sampling dans EXTRA_ARGS : on ne le double pas", cfg: auto,
|
||||
extra: []string{"--spec-draft-sampling", "greedy"}, want: []string{"--spec-type", "draft-mtp"}, wantAuto: true, notes: 1},
|
||||
{name: "moteur sans --spec-draft-sampling : pas de drapeau", cfg: auto, wantAuto: true, notes: 1,
|
||||
want: []string{"--spec-type", "draft-mtp"},
|
||||
si: func(s *serveSysInfo) {
|
||||
s.Help = strings.Replace(s.Help, "--spec-draft-sampling {greedy,probabilistic}", "", 1)
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
si := mtpReady()
|
||||
if c.si != nil {
|
||||
c.si(&si)
|
||||
}
|
||||
got, notes, isAuto := specArgs(c.cfg, c.extra, si)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("args = %q, attendu %q", got, c.want)
|
||||
}
|
||||
if isAuto != c.wantAuto {
|
||||
t.Errorf("auto = %v, attendu %v", isAuto, c.wantAuto)
|
||||
}
|
||||
if len(notes) != c.notes {
|
||||
t.Errorf("%d notes, attendu %d : %q", len(notes), c.notes, notes)
|
||||
}
|
||||
if specMode(c.cfg) == "auto" && slices.Contains(got, "probabilistic") {
|
||||
t.Errorf("SPEC=auto ne doit jamais choisir le tirage probabiliste : %q", got)
|
||||
}
|
||||
for _, a := range got {
|
||||
if a == "--draft" || a == "--draft-max" || a == "--draft-n" || a == "-ctkd" || a == "-ctvd" {
|
||||
t.Errorf("drapeau interdit %q", a)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// La spéculation passe avant EXTRA_ARGS, qui garde le dernier mot.
|
||||
func TestBuildServeArgsSpec(t *testing.T) {
|
||||
si := mtpReady()
|
||||
args, _, _ := buildServeArgs(map[string]string{"SPEC": "auto"}, []string{"--jinja"}, "/bin/llama-server", si)
|
||||
i := slices.Index(args, "--spec-type")
|
||||
if i < 0 || args[i+1] != "draft-mtp" {
|
||||
t.Fatalf("--spec-type draft-mtp absent : %q", args)
|
||||
}
|
||||
if j := slices.Index(args, "--jinja"); j < i {
|
||||
t.Errorf("EXTRA_ARGS doit fermer la marche : %q", args)
|
||||
}
|
||||
args, _, _ = buildServeArgs(map[string]string{}, nil, "/bin/llama-server", si)
|
||||
if slices.Contains(args, "--spec-type") {
|
||||
t.Errorf("SPEC absent : aucun drapeau attendu, %q", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatefulSampler(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"": {"--mirostat", "0"},
|
||||
"--mirostat 1": {"--mirostat=1"},
|
||||
"--samplers adaptive-p": {"--samplers", "penalties;adaptive-p"},
|
||||
"--sampling-seq avec adaptive-p": {"--sampling-seq", "kpa"},
|
||||
}
|
||||
for want, extra := range cases {
|
||||
if got := statefulSampler(extra); got != want {
|
||||
t.Errorf("statefulSampler(%q) = %q, attendu %q", extra, got, want)
|
||||
}
|
||||
}
|
||||
if got := statefulSampler([]string{"--samplers", "top_k;top_p;temperature"}); got != "" {
|
||||
t.Errorf("chaîne sans état : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecAutoVerdict(t *testing.T) {
|
||||
cur := specAutoMark{FP: "abc", Bin: "/e/llama-server", Build: 11351}
|
||||
other := specAutoMark{FP: "abc", Bin: "/e/llama-server", Build: 11400}
|
||||
if why, rec := specAutoVerdict(cur, nil, nil); why != "" || rec {
|
||||
t.Errorf("aucun essai : permis, got %q %v", why, rec)
|
||||
}
|
||||
if why, rec := specAutoVerdict(cur, &cur, map[string]string{}); why == "" || !rec {
|
||||
t.Errorf("jeton resté pour la même combinaison : coupé et inscrit, got %q %v", why, rec)
|
||||
}
|
||||
if why, rec := specAutoVerdict(cur, &other, map[string]string{}); why != "" || rec {
|
||||
t.Errorf("jeton d'un autre build : permis (mise à jour = nouvel essai), got %q %v", why, rec)
|
||||
}
|
||||
if why, rec := specAutoVerdict(cur, nil, map[string]string{cur.key(): "OOM"}); why != "OOM" || rec {
|
||||
t.Errorf("échec inscrit : coupé, got %q %v", why, rec)
|
||||
}
|
||||
}
|
||||
|
||||
// Le cycle complet sur une vraie base : jeton posé, jamais effacé, puis coupé
|
||||
// pour cette combinaison seulement.
|
||||
func TestSpecAutoCheckStore(t *testing.T) {
|
||||
testHome(t)
|
||||
// Ce que fait cmdServe : lire, puis — port libre — ranger et poser le jeton.
|
||||
launch := func(cur specAutoMark, wantAttempt bool) string {
|
||||
why, record := specAutoPeek(cur)
|
||||
specAutoSettle(cur, why, record, wantAttempt && why == "")
|
||||
return why
|
||||
}
|
||||
cur := specAutoMark{FP: "abc", Bin: "/e/llama-server", Build: 11351}
|
||||
if why := launch(cur, true); why != "" {
|
||||
t.Fatalf("base vide : permis, got %q", why)
|
||||
}
|
||||
// Second « loki serve » refusé (port pris) : il a lu, sans rien ranger. Le
|
||||
// jeton du premier, qui charge encore, doit rester intact.
|
||||
if why, _ := specAutoPeek(cur); why == "" {
|
||||
t.Fatal("jeton présent : le lecteur doit le voir")
|
||||
}
|
||||
var a specAutoMark
|
||||
if !getJSON(bkState, specAttemptKey, &a) || a != cur {
|
||||
t.Fatal("specAutoPeek ne doit rien consommer")
|
||||
}
|
||||
if why := launch(cur, true); why == "" {
|
||||
t.Fatal("jeton resté : l'auto doit être coupé")
|
||||
}
|
||||
if getJSON(bkState, specAttemptKey, &a) {
|
||||
t.Error("auto coupé : aucun nouveau jeton")
|
||||
}
|
||||
if why := launch(cur, true); why == "" {
|
||||
t.Error("l'échec doit rester inscrit au lancement suivant")
|
||||
}
|
||||
updated := cur
|
||||
updated.Build = 11400
|
||||
if why := launch(updated, true); why != "" {
|
||||
t.Errorf("moteur mis à jour : nouvel essai permis, got %q", why)
|
||||
}
|
||||
// Le process web efface SON jeton ; pas celui d'un lancement plus récent.
|
||||
specAttemptClear(cur, "")
|
||||
if !getJSON(bkState, specAttemptKey, &a) || a != updated {
|
||||
t.Fatal("le jeton d'un autre lancement doit rester")
|
||||
}
|
||||
specAttemptClear(updated, "")
|
||||
if getJSON(bkState, specAttemptKey, &a) {
|
||||
t.Fatal("le moteur a répondu : jeton effacé")
|
||||
}
|
||||
if why := launch(updated, true); why != "" {
|
||||
t.Errorf("lancement réussi : permis, got %q", why)
|
||||
}
|
||||
// Répondu mais couches en RAM : échec inscrit pour cette combinaison.
|
||||
specAttemptClear(updated, "couches en RAM")
|
||||
if why, _ := specAutoPeek(updated); why != "couches en RAM" {
|
||||
t.Errorf("échec après chargement : coupé, got %q", why)
|
||||
}
|
||||
// Un jeton d'une autre configuration est jeté sans rien conclure.
|
||||
other := specAutoMark{FP: "zzz", Bin: "/e/llama-server", Build: 11351}
|
||||
specAutoSettle(other, "", false, true)
|
||||
if why := launch(cur, false); why == "" {
|
||||
t.Error("échec de cur toujours inscrit")
|
||||
}
|
||||
if getJSON(bkState, specAttemptKey, &a) {
|
||||
t.Error("le jeton étranger doit être consommé")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastOffload(t *testing.T) {
|
||||
log := strings.Join([]string{
|
||||
"srv load_model: loading model '/models/old.gguf'",
|
||||
"load_tensors: offloaded 40/65 layers to GPU",
|
||||
"srv load_model: loading model '/models/new.gguf'",
|
||||
"load_tensors: offloaded 65/65 layers to GPU",
|
||||
"main: server is listening",
|
||||
}, "\n")
|
||||
if n, m := lastOffload(log); n != 65 || m != 65 {
|
||||
t.Errorf("dernier chargement seulement : got %d/%d", n, m)
|
||||
}
|
||||
if n, m := lastOffload("load_tensors: offloaded 10/65 layers to GPU"); n != 0 || m != 0 {
|
||||
t.Errorf("sans ligne de chargement, on ne conclut rien : got %d/%d", n, m)
|
||||
}
|
||||
if n, m := lastOffload("srv load_model: loading model 'x'\nload_tensors: offloaded 50/65 layers to GPU"); n != 50 || m != 65 {
|
||||
t.Errorf("chargement partiel : got %d/%d", n, m)
|
||||
}
|
||||
// Le journal réel de llama-server : « load_model: initializing » APRÈS le
|
||||
// chargement, et un brouillon chargé à part qui a ses propres lignes.
|
||||
srvLog := strings.Join([]string{
|
||||
"srv load_model: loading model '/models/Qwen3.6-27B.gguf'",
|
||||
"load_tensors: loading model tensors, this can take a while... (load_mode = mmap)",
|
||||
"load_tensors: offloaded 52/65 layers to GPU",
|
||||
"load_tensors: loading model tensors, this can take a while... (load_mode = mmap)",
|
||||
"load_tensors: offloaded 2/2 layers to GPU",
|
||||
"srv load_model: initializing, n_slots = 1, n_ctx_slot = 65536, kv_unified = 'false'",
|
||||
"main: server is listening on http://127.0.0.1:8080",
|
||||
}, "\n")
|
||||
if n, m := lastOffload(srvLog); n != 52 || m != 65 {
|
||||
t.Errorf("modèle partiel, brouillon complet : le pire compte, got %d/%d", n, m)
|
||||
}
|
||||
full := strings.Replace(srvLog, "52/65", "65/65", 1)
|
||||
if n, m := lastOffload(full); n != m || m == 0 {
|
||||
t.Errorf("tout sur GPU : got %d/%d", n, m)
|
||||
}
|
||||
}
|
||||
|
||||
// helpNgram : un moteur qui connaît les drapeaux actuels de ngram-mod.
|
||||
const helpNgram = helpSpec + `--spec-ngram-mod-n-min N minimum number of ngram tokens (default: 48)
|
||||
--spec-ngram-mod-n-max N maximum number of ngram tokens (default: 64)
|
||||
--spec-ngram-mod-n-match N ngram-mod lookup length (default: 24)
|
||||
`
|
||||
|
||||
func TestSpecArgsNgram(t *testing.T) {
|
||||
ngram := []string{"--spec-type", "ngram-mod",
|
||||
"--spec-ngram-mod-n-match", "24", "--spec-ngram-mod-n-min", "48", "--spec-ngram-mod-n-max", "64"}
|
||||
both := []string{"--spec-type", "draft-mtp,ngram-mod",
|
||||
"--spec-ngram-mod-n-match", "24", "--spec-ngram-mod-n-min", "48", "--spec-ngram-mod-n-max", "64",
|
||||
"--spec-draft-sampling", "greedy"}
|
||||
cfgOf := func(kv ...string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
m[kv[i]] = kv[i+1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
dense := func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "llama", BlockCount: 32} }
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
si func(*serveSysInfo)
|
||||
want []string
|
||||
notes int
|
||||
}{
|
||||
{name: "ngram, modèle dense : forme explicite, jamais --spec-default",
|
||||
cfg: cfgOf("SPEC", "ngram"), si: dense, want: ngram, notes: 1},
|
||||
{name: "ngram+mtp s'écrit aussi dans l'autre sens",
|
||||
cfg: cfgOf("SPEC", "ngram+mtp"), want: both, notes: 2},
|
||||
{name: "ngram sur un modèle hybride à tête MTP : tête inutilisée et points de reprise dits",
|
||||
cfg: cfgOf("SPEC", "ngram"), want: ngram, notes: 3},
|
||||
{name: "ngram, moteur qui n'a que le type dans sa liste : rien",
|
||||
cfg: cfgOf("SPEC", "ngram"), notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpec }},
|
||||
{name: "mtp+ngram, moteur sans drapeaux ngram-mod : rien",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram"), notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpec }},
|
||||
{name: "mtp+ngram avec la tête dans le fichier : une seule liste de types",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram"), want: both, notes: 2},
|
||||
{name: "mtp+ngram sans tête MTP : n-grammes seuls, jamais « failed to create MTP context »",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram"), si: dense, want: ngram, notes: 1},
|
||||
{name: "mtp+ngram, moteur sans draft-mtp : n-grammes seuls",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram"), want: ngram, notes: 2,
|
||||
si: func(s *serveSysInfo) {
|
||||
s.Help = strings.ReplaceAll(s.Help, "draft-mtp", "draft-xxx")
|
||||
}},
|
||||
{name: "mtp+ngram avec un petit modèle brouillon : n-grammes seuls, pas de -md",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram", "MODEL_DRAFT", "Qwen3-0.6B.gguf"), want: ngram, notes: 2,
|
||||
si: func(s *serveSysInfo) {
|
||||
s.Draft = "/models/Qwen3-0.6B.gguf"
|
||||
s.DraftGGUF = &GGUFInfo{Arch: "qwen3", BlockCount: 28}
|
||||
}},
|
||||
{name: "mtp+ngram avec la tête MTP publiée à part",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram", "MODEL_DRAFT", "mtp-q.gguf"), notes: 2,
|
||||
want: append([]string{"-md", "/models/mtp-q.gguf"}, both...),
|
||||
si: func(s *serveSysInfo) {
|
||||
s.GGUF.HasNextNTensor = false
|
||||
s.Draft = "/models/mtp-q.gguf"
|
||||
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
|
||||
}},
|
||||
{name: "ngram + MODEL_DRAFT : brouillon ignoré, et dit",
|
||||
cfg: cfgOf("SPEC", "ngram", "MODEL_DRAFT", "mtp-q.gguf"), si: dense, want: ngram, notes: 2},
|
||||
{name: "ngram + SPEC_N_MAX : ignoré, et dit",
|
||||
cfg: cfgOf("SPEC", "ngram", "SPEC_N_MAX", "4"), si: dense, want: ngram, notes: 2},
|
||||
{name: "--spec-type dans EXTRA_ARGS : Loki se tait",
|
||||
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--spec-type", "ngram-simple"}, notes: 1},
|
||||
{name: "--spec_type=… dans EXTRA_ARGS : Loki se tait",
|
||||
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--spec_type=ngram-mod"}, notes: 1},
|
||||
{name: "--spec-default dans EXTRA_ARGS : Loki se tait",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"--spec-default"}, notes: 1},
|
||||
{name: "--spec-ngram-mod-n-max dans EXTRA_ARGS : Loki se tait",
|
||||
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--spec-ngram-mod-n-max=16"}, notes: 1},
|
||||
{name: "-md dans EXTRA_ARGS : Loki se tait",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"-md", "d.gguf"}, notes: 1},
|
||||
{name: "LLAMA_ARG_SPEC_TYPE posée : Loki se tait", cfg: cfgOf("SPEC", "ngram"), notes: 1,
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SPEC_TYPE"] = "ngram-mod" }},
|
||||
{name: "ngram + --spec-draft-n-max à la main : rien",
|
||||
cfg: cfgOf("SPEC", "ngram"), si: dense, extra: []string{"--spec-draft-n-max", "8"}, notes: 1},
|
||||
{name: "mtp+ngram + --spec-draft-n-max à la main : la règle de SPEC=mtp",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"--spec-draft-n-max", "8"}, want: both, notes: 2},
|
||||
{name: "experts sur CPU, seuil par défaut : refusé, OP_OFFLOAD_MIN_BATCH suggéré",
|
||||
cfg: cfgOf("SPEC", "ngram"), si: dense, extra: []string{"--n-cpu-moe", "40"}, notes: 1},
|
||||
{name: "experts sur CPU, mtp+ngram : refusé aussi",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"-ot", "exps=CPU"}, notes: 1},
|
||||
{name: "experts sur CPU, OP_OFFLOAD_MIN_BATCH=64 : encore sous le lot de 65, refusé",
|
||||
cfg: cfgOf("SPEC", "ngram", "OP_OFFLOAD_MIN_BATCH", "64"), si: dense, extra: []string{"-cmoe"}, notes: 1},
|
||||
{name: "experts sur CPU, OP_OFFLOAD_MIN_BATCH=128 : accepté",
|
||||
cfg: cfgOf("SPEC", "ngram", "OP_OFFLOAD_MIN_BATCH", "128"), si: dense, extra: []string{"-cmoe"}, want: ngram, notes: 1},
|
||||
{name: "experts sur CPU, GGML_OP_OFFLOAD_MIN_BATCH=256 dans l'environnement : accepté",
|
||||
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--n-cpu-moe", "40"}, want: ngram, notes: 1,
|
||||
si: func(s *serveSysInfo) {
|
||||
dense(s)
|
||||
s.UserEnv = map[string]string{"GGML_OP_OFFLOAD_MIN_BATCH": "256"}
|
||||
}},
|
||||
{name: "experts sur CPU, modèle plus gros que la RAM : accepté avec l'avertissement",
|
||||
cfg: cfgOf("SPEC", "ngram", "OP_OFFLOAD_MIN_BATCH", "128"), extra: []string{"--n-cpu-moe", "40"},
|
||||
want: ngram, notes: 2,
|
||||
si: func(s *serveSysInfo) {
|
||||
dense(s)
|
||||
s.ModelBytes, s.RAMMiB = 82<<30, 64<<10
|
||||
}},
|
||||
{name: "NGL imposé : logits en VRAM dits",
|
||||
cfg: cfgOf("SPEC", "ngram", "NGL", "40"), si: dense, want: ngram, notes: 2},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
si := mtpReady()
|
||||
si.Help = helpNgram
|
||||
if c.si != nil {
|
||||
c.si(&si)
|
||||
}
|
||||
got, notes, isAuto := specArgs(c.cfg, c.extra, si)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("args = %q, attendu %q", got, c.want)
|
||||
}
|
||||
if isAuto {
|
||||
t.Error("ngram n'est jamais automatique")
|
||||
}
|
||||
if len(notes) != c.notes {
|
||||
t.Errorf("%d notes, attendu %d : %q", len(notes), c.notes, notes)
|
||||
}
|
||||
for _, a := range got {
|
||||
if a == "--spec-default" || strings.HasPrefix(a, "--spec-synth") {
|
||||
t.Errorf("drapeau interdit %q", a)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sans SPEC, la même ligne qu'avant, quel que soit le moteur.
|
||||
func TestBuildServeArgsNgramDefault(t *testing.T) {
|
||||
si := mtpReady()
|
||||
si.Help = helpNgram
|
||||
base, _, _ := buildServeArgs(map[string]string{}, nil, "/bin/llama-server", si)
|
||||
for _, a := range base {
|
||||
if strings.HasPrefix(a, "--spec") {
|
||||
t.Fatalf("SPEC absent : aucun drapeau de spéculation, %q", base)
|
||||
}
|
||||
}
|
||||
got, _, _ := buildServeArgs(map[string]string{"SPEC": "ngram"}, []string{"--jinja"}, "/bin/llama-server", si)
|
||||
i := slices.Index(got, "--spec-type")
|
||||
if i < 0 || got[i+1] != "ngram-mod" || slices.Index(got, "--jinja") < i {
|
||||
t.Fatalf("SPEC=ngram : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthAcceptanceWarned(t *testing.T) {
|
||||
for _, extra := range [][]string{{"--spec-synth-len", "3"}, {"--spec-synth-rates=0.5,0.5"}} {
|
||||
if n := lossyCacheNotes(extra, serveSysInfo{ArgEnv: map[string]string{}}); len(n) != 1 ||
|
||||
!strings.Contains(n[0], "AU HASARD") {
|
||||
t.Errorf("%q : %q", extra, n)
|
||||
}
|
||||
}
|
||||
si := serveSysInfo{ArgEnv: map[string]string{"LLAMA_ARG_SPEC_SYNTH_LEN": "3"}}
|
||||
if n := lossyCacheNotes(nil, si); len(n) != 1 {
|
||||
t.Errorf("variable : %q", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Parallélisme de tenseurs (SPLIT_MODE=tensor, off par défaut) : expérimental.
|
||||
//
|
||||
// La découpe par couches (défaut de llama.cpp) donne à chaque carte des
|
||||
// couches entières, traversées l'une après l'autre. En mode tensor, chaque
|
||||
// couche est coupée entre les cartes, qui calculent en même temps puis
|
||||
// additionnent leurs morceaux (all-reduce). Sur deux RTX 4090, l'amont mesure
|
||||
// +21-30 % au décodage, mais -37 % au prefill — et la boucle d'agent de Loki
|
||||
// relit beaucoup. Sur deux GeForce en PCIe sans P2P, la somme passe par
|
||||
// l'hôte : un gain net par tour est loin d'être acquis. D'où l'opt-in, et une
|
||||
// longue liste de refus propres plutôt qu'un moteur qui meurt en boucle.
|
||||
//
|
||||
// Fidélité : l'all-reduce change l'ordre des additions (bruit de virgule
|
||||
// flottante), mais le chemin NCCL va plus loin — au-delà de 32 768 éléments il
|
||||
// COMPRESSE les sommes partielles en BF16 (ggml-cuda.cu), soit à chaque couche
|
||||
// de chaque prefill : une perte de précision du calcul, de la classe d'une
|
||||
// quantification. Loki pose donc GGML_CUDA_ALLREDUCE=internal (réduction dans
|
||||
// le type natif), sauf si l'utilisateur a choisi lui-même. Le cache KV doit
|
||||
// rester f16, bf16 ou f32 : le mode tensor ne gère pas les caches quantifiés,
|
||||
// et Loki ne réécrit jamais un type de cache d'office (ça change le budget
|
||||
// VRAM). Pas de --fit en mode tensor (common/fit.cpp) : Loki estime la VRAM
|
||||
// lui-même et refuse si le compte ne tient pas, sans jamais réduire CTX.
|
||||
|
||||
// splitDev : une carte telle que la voit CE moteur (--list-devices), dans son
|
||||
// ordre — celui que suivent -ts et --device.
|
||||
type splitDev struct {
|
||||
ID string // CUDA0, Vulkan1…
|
||||
TotalMiB int64
|
||||
}
|
||||
|
||||
// splitTensorDenyArch : les architectures que llama.cpp refuse en mode tensor
|
||||
// (llm_arch_supports_sm_tensor) — il mourrait au chargement, et systemd
|
||||
// relancerait en boucle. qwen4exp n'y est pas, mais Loki l'écarte : son preset
|
||||
// place les experts sur CPU avec un cache q8_0, l'inverse de ce qu'exige le
|
||||
// mode tensor.
|
||||
var splitTensorDenyArch = map[string]bool{
|
||||
"grok": true, "mpt": true, "plamo2": true, "minicpm3": true, "gemma3n": true, "mamba": true, "mamba2": true,
|
||||
"jamba": true, "falcon-h1": true, "olmo2": true, "olmoe": true, "deepseek2": true, "deepseek32": true,
|
||||
"hy_v4": true, "dots3note": true, "glm-dsa": true, "bitnet": true, "t5": true, "nemotron_h": true,
|
||||
"nemotron_h_moe": true, "granitehybrid": true, "minimax-01": true, "minimax-m2": true, "minimax-m3": true,
|
||||
"mistral4": true, "kimi-linear": true, "bailingmoe3": true, "kimi-k3": true, "glm5-next": true,
|
||||
"qwen3tts": true, "qwen4exp": true,
|
||||
}
|
||||
|
||||
// splitArgEnv : les variables qui décident de ce que SPLIT_MODE règlerait.
|
||||
var splitArgEnv = []string{"LLAMA_ARG_FLASH_ATTN", "LLAMA_ARG_BACKEND_SAMPLING", "LLAMA_ARG_TENSOR_SPLIT"}
|
||||
|
||||
// splitModeKey lit SPLIT_MODE : "" (clé absente, off ou layer — le défaut du
|
||||
// moteur, rien à poser), "tensor", ou "?" si illisible.
|
||||
func splitModeKey(cfg map[string]string) string {
|
||||
switch v := strings.ToLower(strings.TrimSpace(cfg["SPLIT_MODE"])); v {
|
||||
case "", "off", "layer", "non", "no", "0":
|
||||
return ""
|
||||
case "tensor":
|
||||
return v
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// helpSupportsSplitTensor : la liste LITTÉRALE des valeurs de -sm. Le mot
|
||||
// « tensor » seul est partout dans l'aide (--tensor-split, --override-tensor) :
|
||||
// s'y fier ferait passer -sm tensor à un moteur qui le refuse au démarrage.
|
||||
func helpSupportsSplitTensor(help string) bool {
|
||||
return strings.Contains(help, "{none,layer,row,tensor}")
|
||||
}
|
||||
|
||||
// probeSplit : seulement si SPLIT_MODE=tensor et que le moteur le propose —
|
||||
// sans la clé, rien n'est lu ni lancé. --list-devices tourne dans
|
||||
// l'environnement du vrai lancement (bibliothèques, CUDA_VISIBLE_DEVICES et
|
||||
// PCI_BUS_ID déjà posés par cmdServe) : même ordre de cartes que le moteur.
|
||||
func probeSplit(cfg map[string]string, bin string, si *serveSysInfo) {
|
||||
if splitModeKey(cfg) != "tensor" || !helpSupportsSplitTensor(si.Help) {
|
||||
return
|
||||
}
|
||||
if si.ArgEnv == nil {
|
||||
si.ArgEnv = map[string]string{}
|
||||
}
|
||||
for _, k := range splitArgEnv {
|
||||
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
if si.UserEnv == nil {
|
||||
si.UserEnv = map[string]string{}
|
||||
}
|
||||
if v := os.Getenv("GGML_CUDA_ALLREDUCE"); v != "" {
|
||||
si.UserEnv["GGML_CUDA_ALLREDUCE"] = v
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
out, err := hideCmd(exec.CommandContext(ctx, bin, "--list-devices")).CombinedOutput()
|
||||
if err != nil {
|
||||
return // sortie tronquée (carte pleine, moteur planté) : on ne s'y fie pas
|
||||
}
|
||||
for _, d := range parseListDevices(string(out)) {
|
||||
id, _ := d["id"].(string)
|
||||
total, _ := d["total_mib"].(int)
|
||||
si.SplitDevs = append(si.SplitDevs, splitDev{ID: id, TotalMiB: int64(total)})
|
||||
}
|
||||
}
|
||||
|
||||
// splitPlan décide de SPLIT_MODE=tensor pour ce lancement. Fonction pure.
|
||||
//
|
||||
// on : accepté ; la ligne gagne -sm tensor et ses compagnons
|
||||
// why : pourquoi refusé (vide = clé absente)
|
||||
// devs : les cartes du découpage, dans l'ordre du moteur
|
||||
// needMiB : la VRAM estimée (poids + cache de CTX jetons)
|
||||
func splitPlan(cfg map[string]string, extra []string, si serveSysInfo) (on bool, why string, devs []splitDev, needMiB int64) {
|
||||
switch splitModeKey(cfg) {
|
||||
case "":
|
||||
return false, "", nil, 0
|
||||
case "?":
|
||||
return false, "SPLIT_MODE=" + strings.TrimSpace(cfg["SPLIT_MODE"]) + " illisible (tensor ou off)", nil, 0
|
||||
}
|
||||
refuse := func(why string) (bool, string, []splitDev, int64) { return false, why, nil, 0 }
|
||||
if isExternalConfig(cfg) {
|
||||
return refuse("preset externe")
|
||||
}
|
||||
if !helpSupportsSplitTensor(si.Help) {
|
||||
return refuse("ce moteur ne propose pas -sm tensor")
|
||||
}
|
||||
if sm := flagValue(extra, "-sm", "--split-mode"); sm != "" {
|
||||
return refuse("-sm " + sm + " déjà dans EXTRA_ARGS")
|
||||
}
|
||||
if si.ArgEnv["LLAMA_ARG_SPLIT_MODE"] != "" {
|
||||
return refuse("LLAMA_ARG_SPLIT_MODE déjà posée")
|
||||
}
|
||||
fa := flagValue(extra, "-fa", "--flash-attn")
|
||||
if fa == "" {
|
||||
fa = si.ArgEnv["LLAMA_ARG_FLASH_ATTN"]
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(fa)) {
|
||||
case "off", "disabled", "false", "0":
|
||||
return refuse("Flash Attention coupée (-fa " + fa + ") : le mode tensor l'exige")
|
||||
}
|
||||
if hasAnyFlag(extra, "-bs", "--backend-sampling") || envTruthy(si.ArgEnv["LLAMA_ARG_BACKEND_SAMPLING"]) {
|
||||
return refuse("échantillonnage sur GPU (--backend-sampling) non pris en charge en mode tensor")
|
||||
}
|
||||
k, v, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
|
||||
if !splitKVOK(k) || !splitKVOK(v) {
|
||||
return refuse(fmt.Sprintf("cache KV %s/%s : le mode tensor n'accepte que f16, bf16 ou f32 "+
|
||||
"(jamais réécrit d'office : la VRAM changerait)", kvName(k), kvName(v)))
|
||||
}
|
||||
if r := tensorOverride(extra, si.ArgEnv); r != "" {
|
||||
return refuse(r + " : le mode tensor met tout sur les cartes")
|
||||
}
|
||||
if !kvOffloaded(extra, si.ArgEnv) {
|
||||
return refuse("cache KV sur CPU")
|
||||
}
|
||||
switch {
|
||||
case si.GGUF == nil || si.ModelBytes <= 0:
|
||||
return refuse("métadonnées ou taille du modèle illisibles")
|
||||
case splitTensorDenyArch[strings.ToLower(si.GGUF.Arch)]:
|
||||
return refuse("architecture " + si.GGUF.Arch + " exclue du mode tensor")
|
||||
}
|
||||
if _, why := splitNGL(cfg, extra, si.GGUF.BlockCount); why != "" {
|
||||
return refuse(why)
|
||||
}
|
||||
ctxS, ok := ctxFixed(cfg, extra)
|
||||
if !ok {
|
||||
return refuse("contexte « " + ctxS + " » non chiffré : pas de --fit en mode tensor, donne un CTX explicite")
|
||||
}
|
||||
if sideSlotOn(cfg) {
|
||||
return refuse("SIDE_SLOT : cache doublé, sans --fit pour rattraper")
|
||||
}
|
||||
if specMode(cfg) != "off" || specUserSet(extra, si.ArgEnv) {
|
||||
return refuse("décodage spéculatif : brouillon et vérification par lots non validés en mode tensor")
|
||||
}
|
||||
devs, why = splitDevices(extra, si)
|
||||
if why != "" {
|
||||
return refuse(why)
|
||||
}
|
||||
// VRAM : poids + cache de CTX jetons (et état récurrent) contre 90 % des
|
||||
// cartes, la marge de SIDE_SLOT. Les tampons de calcul n'y sont pas : c'est
|
||||
// un minimum, pas une promesse — le journal dira le reste.
|
||||
ctx, _ := strconv.Atoi(ctxS)
|
||||
perTok, rec := ggufStateBytes(*si.GGUF, k, v)
|
||||
if perTok <= 0 {
|
||||
return refuse("taille du cache KV inconnue pour ce modèle")
|
||||
}
|
||||
var total int64
|
||||
for _, d := range devs {
|
||||
total += d.TotalMiB
|
||||
}
|
||||
mm := int64(0)
|
||||
if si.MMProj != "" {
|
||||
if st, err := os.Stat(si.MMProj); err == nil {
|
||||
mm = st.Size()
|
||||
}
|
||||
}
|
||||
need := float64(si.ModelBytes+mm) + perTok*float64(ctx) + float64(rec)
|
||||
needMiB = int64(math.Ceil(need / (1 << 20)))
|
||||
if need > 0.9*float64(total)*(1<<20) {
|
||||
return refuse(fmt.Sprintf("VRAM insuffisante sans --fit : ~%d Mio (poids + cache de %d jetons) pour %d Mio "+
|
||||
"sur %d cartes — CTX n'est jamais réduit d'office", needMiB, ctx, total, len(devs)))
|
||||
}
|
||||
return true, "", devs, needMiB
|
||||
}
|
||||
|
||||
func splitKVOK(t string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "", "f16", "bf16", "f32":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func kvName(t string) string {
|
||||
if t == "" {
|
||||
return "f16"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// splitNGL : les couches GPU en mode tensor, où tout doit être sur les cartes.
|
||||
// Un -ngl d'EXTRA_ARGS reste celui de l'utilisateur (rien à ajouter) s'il dit
|
||||
// « tout » ; sinon NGL est traduit en « -ngl all » (999, auto et la clé absente
|
||||
// visent déjà le tout-GPU, mais auto ne fait rien sans --fit). Une valeur qui
|
||||
// laisse des couches au CPU est refusée.
|
||||
func splitNGL(cfg map[string]string, extra []string, blocks int) (args []string, why string) {
|
||||
all := func(v string) bool {
|
||||
v = strings.ToLower(strings.TrimSpace(v))
|
||||
if v == "all" || v == "999" {
|
||||
return true
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
return err == nil && blocks > 0 && n >= blocks+1
|
||||
}
|
||||
if v := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers"); v != "" {
|
||||
if all(v) {
|
||||
return nil, ""
|
||||
}
|
||||
return nil, "-ngl " + v + " dans EXTRA_ARGS : le mode tensor veut toutes les couches sur les cartes (all)"
|
||||
}
|
||||
switch v := strings.TrimSpace(cfg["NGL"]); {
|
||||
case v == "" || strings.EqualFold(v, "auto") || all(v):
|
||||
return []string{"-ngl", "all"}, ""
|
||||
default:
|
||||
return nil, "NGL=" + v + " : le mode tensor veut toutes les couches sur les cartes (all)"
|
||||
}
|
||||
}
|
||||
|
||||
// splitDevices : les cartes du découpage. --device (EXTRA_ARGS ou variable)
|
||||
// choisit lesquelles, dans son ordre ; sinon toutes celles que liste le
|
||||
// moteur. Toutes CUDA (l'all-reduce n'existe qu'entre cartes CUDA), deux au
|
||||
// moins.
|
||||
func splitDevices(extra []string, si serveSysInfo) ([]splitDev, string) {
|
||||
if len(si.SplitDevs) == 0 {
|
||||
return nil, "cartes inconnues (--list-devices n'a rien répondu)"
|
||||
}
|
||||
devs := si.SplitDevs
|
||||
sel := flagValue(extra, "-dev", "--device")
|
||||
if sel == "" {
|
||||
sel = si.ArgEnv["LLAMA_ARG_DEVICE"]
|
||||
}
|
||||
if sel = strings.TrimSpace(sel); sel != "" {
|
||||
devs = nil
|
||||
for _, name := range strings.Split(sel, ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
found := false
|
||||
for _, d := range si.SplitDevs {
|
||||
if strings.EqualFold(d.ID, name) {
|
||||
devs, found = append(devs, d), true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, "--device " + name + " inconnu de ce moteur"
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, d := range devs {
|
||||
if !strings.HasPrefix(strings.ToUpper(d.ID), "CUDA") {
|
||||
return nil, d.ID + " n'est pas une carte CUDA"
|
||||
}
|
||||
}
|
||||
if len(devs) < 2 {
|
||||
return nil, "une seule carte"
|
||||
}
|
||||
return devs, ""
|
||||
}
|
||||
|
||||
// splitTS : la répartition par défaut, au prorata de la VRAM TOTALE de chaque
|
||||
// carte (la libre varie avec ce qui tourne encore au lancement), en pour cent.
|
||||
func splitTS(devs []splitDev) string {
|
||||
var total int64
|
||||
for _, d := range devs {
|
||||
total += d.TotalMiB
|
||||
}
|
||||
if total <= 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, len(devs))
|
||||
for i, d := range devs {
|
||||
parts[i] = strconv.FormatInt(int64(math.Round(100*float64(d.TotalMiB)/float64(total))), 10)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// splitTensorArgs : ce que SPLIT_MODE=tensor ajoute à la ligne, à la place du -ngl
|
||||
// habituel (nglOut), l'environnement et les notes. on=false : rien, et la
|
||||
// ligne reste celle d'avant à l'octet près.
|
||||
func splitTensorArgs(cfg map[string]string, extra []string, si serveSysInfo) (on bool, args, ngl []string, env map[string]string, notes []string) {
|
||||
ok, why, devs, needMiB := splitPlan(cfg, extra, si)
|
||||
if !ok {
|
||||
if why != "" {
|
||||
notes = append(notes, "SPLIT_MODE=tensor refusé ("+why+") : découpe par couches, comme sans la clé")
|
||||
}
|
||||
return false, nil, nil, nil, notes
|
||||
}
|
||||
ngl, _ = splitNGL(cfg, extra, si.GGUF.BlockCount)
|
||||
args = []string{"-sm", "tensor"}
|
||||
ts := ""
|
||||
if !hasAnyFlag(extra, "-ts", "--tensor-split") && si.ArgEnv["LLAMA_ARG_TENSOR_SPLIT"] == "" {
|
||||
if ts = splitTS(devs); ts != "" {
|
||||
args = append(args, "-ts", ts)
|
||||
}
|
||||
}
|
||||
// llama.cpp active de lui-même Flash Attention en mode tensor ; l'écrire
|
||||
// le rend lisible dans le journal. Jamais en double d'un -fa de l'utilisateur.
|
||||
if !hasAnyFlag(extra, "-fa", "--flash-attn") && si.ArgEnv["LLAMA_ARG_FLASH_ATTN"] == "" {
|
||||
args = append(args, "-fa", "on")
|
||||
}
|
||||
env = map[string]string{}
|
||||
note := fmt.Sprintf("SPLIT_MODE=tensor (expérimental) : -sm tensor sur %d cartes", len(devs))
|
||||
if ts != "" {
|
||||
note += ", -ts " + ts + " (VRAM totale)"
|
||||
}
|
||||
note += fmt.Sprintf(", ~%d Mio estimés sans --fit ; décodage plus rapide, prefill plus lent — compare le temps par tour", needMiB)
|
||||
switch ar := strings.ToLower(strings.TrimSpace(si.UserEnv["GGML_CUDA_ALLREDUCE"])); ar {
|
||||
case "":
|
||||
env["GGML_CUDA_ALLREDUCE"] = "internal"
|
||||
note += " ; GGML_CUDA_ALLREDUCE=internal : sommes dans le type natif (NCCL les compresserait en BF16, avec perte). " +
|
||||
"« NCCL not compiled in » ou « NCCL is unavailable » au journal : sans effet ici. GeForce sans P2P : les " +
|
||||
"sommes passent par l'hôte ; GGML_CUDA_P2P=1 à poser soi-même si le pilote le permet"
|
||||
case "nccl":
|
||||
note += " ; avertissement : GGML_CUDA_ALLREDUCE=nccl (environnement) — NCCL compresse en BF16 les grosses " +
|
||||
"réductions du prefill : calcul avec perte. « NCCL not compiled in » au journal = repli sur la réduction interne"
|
||||
default:
|
||||
note += " ; GGML_CUDA_ALLREDUCE=" + ar + " (environnement) gardé"
|
||||
}
|
||||
if si.GGUF.Hybrid {
|
||||
note += " ; modèle hybride : état récurrent et points de reprise répartis entre cartes, non validés en amont"
|
||||
}
|
||||
notes = append(notes, note)
|
||||
return true, args, ngl, env, notes
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const helpTensor = helpRecent + `-sm, --split-mode {none,layer,row,tensor}
|
||||
how to split the model across multiple GPUs
|
||||
-ts, --tensor-split N0,N1,N2,... fraction of the model to offload to each GPU
|
||||
`
|
||||
|
||||
// tensorReady : un hybride 64 couches de 16 Gio sur 16 + 12 Gio de VRAM, sur
|
||||
// un moteur qui propose -sm tensor. Le cas où SPLIT_MODE=tensor est accepté.
|
||||
func tensorReady() serveSysInfo {
|
||||
return serveSysInfo{
|
||||
Help: helpTensor,
|
||||
Model: "/models/Qwen3.6-27B.gguf",
|
||||
GGUF: &GGUFInfo{Arch: "qwen35", BlockCount: 64, KeyLen: 256, ValLen: 256, HeadCountKV: 4,
|
||||
Hybrid: true, FullAttnInterval: 4},
|
||||
ModelBytes: 16 << 30,
|
||||
ArgEnv: map[string]string{},
|
||||
UserEnv: map[string]string{},
|
||||
SplitDevs: []splitDev{{ID: "CUDA0", TotalMiB: 16311}, {ID: "CUDA1", TotalMiB: 12288}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitTensorPlan(t *testing.T) {
|
||||
tensor := map[string]string{"SPLIT_MODE": "tensor", "CTX": "65536"}
|
||||
with := func(kv ...string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for k, v := range tensor {
|
||||
m[k] = v
|
||||
}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
m[kv[i]] = kv[i+1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
si func(*serveSysInfo)
|
||||
on bool
|
||||
why string // fragment attendu de la raison du refus
|
||||
}{
|
||||
{name: "clé absente : rien, en silence", cfg: map[string]string{}},
|
||||
{name: "SPLIT_MODE=layer : le défaut, rien", cfg: map[string]string{"SPLIT_MODE": "layer"}},
|
||||
{name: "accepté", cfg: tensor, on: true},
|
||||
{name: "illisible", cfg: with("SPLIT_MODE", "rows"), why: "illisible"},
|
||||
{name: "aide qui ne cite que --tensor-split : refusé",
|
||||
cfg: tensor, why: "-sm tensor", si: func(s *serveSysInfo) { s.Help = helpRecent + "-ts, --tensor-split N0,N1\n" }},
|
||||
{name: "preset externe", cfg: with(extKeyFlag, "1"), why: "externe"},
|
||||
{name: "-sm dans EXTRA_ARGS : l'utilisateur garde la main", cfg: tensor, extra: []string{"-sm", "layer"}, why: "-sm layer"},
|
||||
{name: "LLAMA_ARG_SPLIT_MODE", cfg: tensor, why: "LLAMA_ARG_SPLIT_MODE",
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SPLIT_MODE"] = "row" }},
|
||||
{name: "-fa off : refusé", cfg: tensor, extra: []string{"-fa", "off"}, why: "Flash Attention"},
|
||||
{name: "-fa on : accepté", cfg: tensor, extra: []string{"-fa", "on"}, on: true},
|
||||
{name: "--backend-sampling : refusé", cfg: tensor, extra: []string{"--backend-sampling"}, why: "backend-sampling"},
|
||||
{name: "LLAMA_ARG_BACKEND_SAMPLING : refusé", cfg: tensor, why: "backend-sampling",
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_BACKEND_SAMPLING"] = "1" }},
|
||||
{name: "KV_TYPE=q8_0 : refusé, jamais réécrit", cfg: with("KV_TYPE", "q8_0"), why: "q8_0"},
|
||||
{name: "-ctk q8_0 dans EXTRA_ARGS (preset MoE) : refusé", cfg: tensor,
|
||||
extra: []string{"--cache-type-k", "q8_0", "--cache-type-v", "q8_0"}, why: "q8_0"},
|
||||
{name: "KV_TYPE_V=q4_0 : refusé", cfg: with("KV_TYPE_V", "q4_0"), why: "q4_0"},
|
||||
{name: "KV bf16 : accepté", cfg: with("KV_TYPE", "bf16"), on: true},
|
||||
{name: "--n-cpu-moe : refusé", cfg: tensor, extra: []string{"--n-cpu-moe", "40"}, why: "n-cpu-moe"},
|
||||
{name: "-ot : refusé", cfg: tensor, extra: []string{"-ot", "exps=CPU"}, why: "surcharge"},
|
||||
{name: "cache KV sur CPU : refusé", cfg: tensor, extra: []string{"-nkvo"}, why: "KV sur CPU"},
|
||||
{name: "NGL=40 : refusé", cfg: with("NGL", "40"), why: "NGL=40"},
|
||||
{name: "NGL=999 : accepté", cfg: with("NGL", "999"), on: true},
|
||||
{name: "-ngl 99 dans EXTRA_ARGS (≥ toutes les couches) : accepté", cfg: tensor, extra: []string{"-ngl", "99"}, on: true},
|
||||
{name: "-ngl 30 dans EXTRA_ARGS : refusé", cfg: tensor, extra: []string{"-ngl", "30"}, why: "-ngl 30"},
|
||||
{name: "CTX=0 : refusé", cfg: with("CTX", "0"), why: "non chiffré"},
|
||||
{name: "VRAM insuffisante : refusé, CTX jamais réduit", cfg: with("CTX", "262144"), why: "VRAM insuffisante"},
|
||||
{name: "qwen4exp : refusé", cfg: tensor, why: "qwen4exp", si: func(s *serveSysInfo) { s.GGUF.Arch = "qwen4exp" }},
|
||||
{name: "mamba (refusé par llama.cpp) : refusé proprement", cfg: tensor, why: "mamba",
|
||||
si: func(s *serveSysInfo) { s.GGUF.Arch = "mamba" }},
|
||||
{name: "GGUF illisible", cfg: tensor, why: "illisibles", si: func(s *serveSysInfo) { s.GGUF = nil }},
|
||||
{name: "SPEC=mtp : refusé", cfg: with("SPEC", "mtp"), why: "spéculatif"},
|
||||
{name: "SIDE_SLOT=on : refusé", cfg: with("SIDE_SLOT", "on"), why: "SIDE_SLOT"},
|
||||
{name: "une seule carte", cfg: tensor, why: "une seule carte",
|
||||
si: func(s *serveSysInfo) { s.SplitDevs = s.SplitDevs[:1] }},
|
||||
{name: "cartes inconnues", cfg: tensor, why: "inconnues", si: func(s *serveSysInfo) { s.SplitDevs = nil }},
|
||||
{name: "moteur Vulkan : refusé", cfg: tensor, why: "CUDA",
|
||||
si: func(s *serveSysInfo) { s.SplitDevs = []splitDev{{"Vulkan0", 16311}, {"Vulkan1", 12288}} }},
|
||||
{name: "--device d'une seule carte : refusé", cfg: tensor, extra: []string{"--device", "CUDA0"}, why: "une seule carte"},
|
||||
{name: "--device inconnu : refusé", cfg: tensor, extra: []string{"-dev", "CUDA0,CUDA7"}, why: "CUDA7"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
si := tensorReady()
|
||||
if c.si != nil {
|
||||
c.si(&si)
|
||||
}
|
||||
on, why, _, _ := splitPlan(c.cfg, c.extra, si)
|
||||
if on != c.on {
|
||||
t.Fatalf("on = %v, attendu %v (%q)", on, c.on, why)
|
||||
}
|
||||
if !strings.Contains(why, c.why) || (c.why == "" && why != "") {
|
||||
t.Errorf("raison %q, attendu %q", why, c.why)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitTensorArgs(t *testing.T) {
|
||||
const bin = "/opt/llama/llama-server"
|
||||
cfg := map[string]string{"SPLIT_MODE": "tensor", "CTX": "65536", "NGL": "999"}
|
||||
|
||||
// Clé absente : la ligne et l'environnement d'avant, à l'octet près, même
|
||||
// sur un moteur et une machine où le mode tensor serait possible.
|
||||
plain := map[string]string{"CTX": "65536", "NGL": "999"}
|
||||
got, env, _ := buildServeArgs(plain, nil, bin, tensorReady())
|
||||
si := tensorReady()
|
||||
si.Help, si.SplitDevs = helpRecent, nil
|
||||
want, wantEnv, _ := buildServeArgs(plain, nil, bin, si)
|
||||
if !reflect.DeepEqual(got, want) || !reflect.DeepEqual(env, wantEnv) {
|
||||
t.Fatalf("sans SPLIT_MODE : %q %v, attendu %q %v", got, env, want, wantEnv)
|
||||
}
|
||||
|
||||
got, env, notes := buildServeArgs(cfg, nil, bin, tensorReady())
|
||||
tail := []string{"-ngl", "all", "-sm", "tensor", "-ts", "57,43", "-fa", "on"}
|
||||
if i := slices.Index(got, "-ngl"); i < 0 || !reflect.DeepEqual(got[i:i+len(tail)], tail) {
|
||||
t.Fatalf("args = %q, attendu …%q…", got, tail)
|
||||
}
|
||||
if env["GGML_CUDA_ALLREDUCE"] != "internal" {
|
||||
t.Errorf("GGML_CUDA_ALLREDUCE = %q, attendu internal (NCCL compresse en BF16)", env["GGML_CUDA_ALLREDUCE"])
|
||||
}
|
||||
if len(notes) == 0 || !strings.Contains(strings.Join(notes, "\n"), "NCCL") {
|
||||
t.Errorf("la note doit parler de NCCL : %q", notes)
|
||||
}
|
||||
|
||||
// Les choix de l'utilisateur gagnent et ne sont jamais doublés.
|
||||
extra := []string{"-ts", "60,40", "-fa", "on", "-ngl", "all"}
|
||||
got, _, _ = buildServeArgs(cfg, extra, bin, tensorReady())
|
||||
for _, f := range []string{"-ts", "-fa", "-ngl", "-sm"} {
|
||||
if n := countFlag(got, f); n != 1 {
|
||||
t.Errorf("%s présent %d fois : %q", f, n, got)
|
||||
}
|
||||
}
|
||||
|
||||
// --device dans l'ordre inverse : la répartition suit.
|
||||
got, _, _ = buildServeArgs(cfg, []string{"--device", "CUDA1,CUDA0"}, bin, tensorReady())
|
||||
if argValue(got, "-ts") != "43,57" {
|
||||
t.Errorf("-ts = %q, attendu 43,57", argValue(got, "-ts"))
|
||||
}
|
||||
|
||||
// GGML_CUDA_ALLREDUCE déjà posé : gardé, et NCCL signalé comme avec perte.
|
||||
si = tensorReady()
|
||||
si.UserEnv["GGML_CUDA_ALLREDUCE"] = "nccl"
|
||||
_, env, notes = buildServeArgs(cfg, nil, bin, si)
|
||||
if _, set := env["GGML_CUDA_ALLREDUCE"]; set {
|
||||
t.Errorf("choix de l'utilisateur écrasé : %v", env)
|
||||
}
|
||||
if !strings.Contains(strings.Join(notes, "\n"), "BF16") {
|
||||
t.Errorf("NCCL doit être signalé : %q", notes)
|
||||
}
|
||||
|
||||
// Les réglages qui lisent le placement voient le mode tensor : pas de
|
||||
// graphes Q/K/V en parallèle, pas de --fit-target, pas de file CUDA.
|
||||
withOpts := map[string]string{"CUDA_GRAPH_OPT": "on", "FIT_TARGET": "1024"}
|
||||
for k, v := range cfg {
|
||||
withOpts[k] = v
|
||||
}
|
||||
got, env, _ = buildServeArgs(withOpts, nil, bin, tensorReady())
|
||||
if env["GGML_CUDA_GRAPH_OPT"] != "" || slices.Contains(got, "--fit-target") {
|
||||
t.Errorf("mode tensor : ni GGML_CUDA_GRAPH_OPT ni --fit-target, %q %v", got, env)
|
||||
}
|
||||
|
||||
// Moteur sans -sm tensor : aucun compagnon (-ngl all, -ts, -fa), une note.
|
||||
si = tensorReady()
|
||||
si.Help = helpRecent
|
||||
got, env, notes = buildServeArgs(cfg, nil, bin, si)
|
||||
if slices.Contains(got, "-sm") || slices.Contains(got, "-ts") || slices.Contains(got, "-fa") ||
|
||||
argValue(got, "-ngl") != "auto" || env["GGML_CUDA_ALLREDUCE"] != "" {
|
||||
t.Errorf("refusé : ligne d'avant attendue, %q %v", got, env)
|
||||
}
|
||||
if !strings.Contains(strings.Join(notes, "\n"), "SPLIT_MODE=tensor refusé") {
|
||||
t.Errorf("le refus doit se dire : %q", notes)
|
||||
}
|
||||
}
|
||||
|
||||
func countFlag(args []string, f string) int {
|
||||
n := 0
|
||||
for _, a := range args {
|
||||
if a == f {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestSplitTensorSlotPersist(t *testing.T) {
|
||||
si := tensorReady()
|
||||
si.Help += "--slot-save-path PATH\n"
|
||||
si.SlotDir = "/data/slots"
|
||||
cfg := map[string]string{"SLOT_PERSIST": "on", "HOST": "127.0.0.1", "SPLIT_MODE": "tensor"}
|
||||
if ok, why := slotPersistPlan(cfg, nil, si); ok || !strings.Contains(why, "SPLIT_MODE") {
|
||||
t.Errorf("SLOT_PERSIST avec SPLIT_MODE : refusé attendu, got %v %q", ok, why)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpSupportsSplitTensor(t *testing.T) {
|
||||
if helpSupportsSplitTensor(helpRecent + "-ts, --tensor-split N0,N1 fraction\n--override-tensor\n") {
|
||||
t.Error("« tensor » ailleurs dans l'aide ne suffit pas")
|
||||
}
|
||||
if !helpSupportsSplitTensor(helpTensor) {
|
||||
t.Error("liste littérale de -sm : oui")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Threads CPU de llama-server.
|
||||
//
|
||||
// Loki passait toujours « -t <THREADS|0> -tb <THREADS_BATCH|0> ». Or pour
|
||||
// llama.cpp, 0 (ou moins) veut dire std::thread::hardware_concurrency() : TOUS
|
||||
// les threads logiques, frères SMT compris. Avec l'attente active par défaut
|
||||
// (--poll 50), deux threads qui se disputent le même cœur physique se gênent
|
||||
// plus qu'ils ne s'aident — c'est le décodage des experts MoE sur CPU
|
||||
// (--n-cpu-moe) qui paie. Le « 0 = auto » de l'interface mentait donc : ce
|
||||
// n'était pas l'auto de llama.cpp.
|
||||
//
|
||||
// Le vrai auto, c'est l'ABSENCE du drapeau : n_threads reste à -1 et le moteur
|
||||
// prend common_cpu_get_num_math() — les cœurs physiques (sans les cœurs E sur
|
||||
// Intel hybride, sous Linux x86). Un -tb absent recopie -t, et le brouillon
|
||||
// spéculatif hérite des deux. Le nombre de threads ne change que la répartition
|
||||
// du travail, pas le calcul du modèle.
|
||||
//
|
||||
// Effet de bord assumé : sans -t de Loki, une variable LLAMA_ARG_THREADS posée
|
||||
// dans l'environnement du moteur s'applique enfin (le -t 0 l'écrasait).
|
||||
//
|
||||
// Gain non mesuré sur la machine de référence : avant d'en revendiquer un,
|
||||
// comparer tg du preset MoE à physiques, physiques-1 et logiques (MTP actif et
|
||||
// coupé, la vérification spéculative passant par n_threads_batch). Si les
|
||||
// logiques gagnent, poser THREADS dans CE preset plutôt que changer le défaut.
|
||||
// Pour voir le nombre retenu : -lv 4 dans EXTRA_ARGS (system_info est en TRACE).
|
||||
|
||||
// cpuBudget est ce que la sonde de conteneur (cpusetThreads) a conclu. N = 0 :
|
||||
// rien à dire, llama.cpp choisit seul.
|
||||
type cpuBudget struct {
|
||||
N int // threads à imposer via -t
|
||||
Engine int // ce que llama.cpp aurait pris seul, pour la note
|
||||
Why string // raison, en clair, pour stderr
|
||||
}
|
||||
|
||||
// threadCount lit THREADS / THREADS_BATCH. Vide ou 0 : auto, sans un mot. Une
|
||||
// valeur illisible (« auto ») ou négative faisait jusqu'ici tomber le moteur en
|
||||
// boucle sur son analyse d'arguments ; elle est désormais ignorée — mais on le
|
||||
// DIT, plutôt que de la jeter en douce.
|
||||
func threadCount(key, v string) (int, string) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return 0, ""
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
return 0, fmt.Sprintf("%s=%s ignoré (pas un nombre de threads ≥ 0) → automatique", key, v)
|
||||
}
|
||||
return n, ""
|
||||
}
|
||||
|
||||
// threadArgs compose -t / -tb. Fonction pure, comme nglArgs : la sonde du
|
||||
// conteneur arrive déjà tranchée dans auto.
|
||||
//
|
||||
// THREADS=N>0 → -t N
|
||||
// THREADS vide/0, sonde N>0 → -t N (conteneur à l'étroit, voir cpusetThreads)
|
||||
// THREADS vide/0 → rien (cœurs physiques, choisis par llama.cpp)
|
||||
// THREADS_BATCH=N>0 → -tb N
|
||||
// THREADS_BATCH vide/0 → rien (le moteur recopie -t)
|
||||
// … sauf -Cb/--poll-batch… → -tb <valeur de -t, sinon 0> (voir plus bas)
|
||||
//
|
||||
// Un -t / -tb déjà écrit dans EXTRA_ARGS gagne : on ne double pas le drapeau.
|
||||
// Un masque d'affinité posé à la main (-C, -Cr, --cpu-strict) désarme la sonde :
|
||||
// l'utilisateur a pris la main sur le placement, on ne devine rien par-dessus.
|
||||
//
|
||||
// Piège du -tb absent : llama.cpp ne recopie pas seulement le NOMBRE de -t, il
|
||||
// remplace TOUT le réglage CPU du batch par celui de -t (postprocess_cpu_params,
|
||||
// « cpuparams = *role_model »). Un -Cb, --poll-batch ou --prio-batch écrit dans
|
||||
// EXTRA_ARGS serait alors effacé sans un mot — le -tb 0 d'avant le protégeait.
|
||||
// Dans ce cas seulement, on pose donc -tb : la valeur de -t si on la connaît
|
||||
// (« comme THREADS »), sinon 0, l'ancien comportement.
|
||||
func threadArgs(threads, threadsBatch string, extra []string, auto cpuBudget) (args, notes []string) {
|
||||
t, note := threadCount("THREADS", threads)
|
||||
if note != "" {
|
||||
notes = append(notes, note)
|
||||
}
|
||||
tVal := "" // valeur de -t effective, si on la connaît
|
||||
switch {
|
||||
case hasAnyFlag(extra, "-t", "--threads"):
|
||||
tVal = flagValue(extra, "-t", "--threads")
|
||||
case t > 0:
|
||||
tVal = strconv.Itoa(t)
|
||||
args = append(args, "-t", tVal)
|
||||
case auto.N > 0 && !hasAnyFlag(extra, "-C", "--cpu-mask", "-Cr", "--cpu-range", "--cpu-strict"):
|
||||
tVal = strconv.Itoa(auto.N)
|
||||
args = append(args, "-t", tVal)
|
||||
notes = append(notes, fmt.Sprintf("threads auto → -t %d : %s (llama.cpp seul en lancerait %d)",
|
||||
auto.N, auto.Why, auto.Engine))
|
||||
}
|
||||
tb, note := threadCount("THREADS_BATCH", threadsBatch)
|
||||
if note != "" {
|
||||
notes = append(notes, note)
|
||||
}
|
||||
switch {
|
||||
case hasAnyFlag(extra, "-tb", "--threads-batch"):
|
||||
case tb > 0:
|
||||
args = append(args, "-tb", strconv.Itoa(tb))
|
||||
case hasAnyFlag(extra, batchCPUFlags...):
|
||||
if tVal == "" {
|
||||
tVal = "0"
|
||||
notes = append(notes, "EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que "+
|
||||
"llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre")
|
||||
}
|
||||
args = append(args, "-tb", tVal)
|
||||
}
|
||||
return args, notes
|
||||
}
|
||||
|
||||
// batchCPUFlags : les réglages CPU propres au batch, qu'un -tb absent effacerait.
|
||||
var batchCPUFlags = []string{"-Cb", "--cpu-mask-batch", "-Crb", "--cpu-range-batch",
|
||||
"--cpu-strict-batch", "--prio-batch", "--poll-batch"}
|
||||
|
||||
// flagValue renvoie la valeur de la DERNIÈRE occurrence de l'un des drapeaux
|
||||
// (« -t 6 » ou « -t=6 ») — celle que retient le moteur. Vide s'il n'y en a pas.
|
||||
func flagValue(args []string, flags ...string) string {
|
||||
v := ""
|
||||
for i, a := range args {
|
||||
name, val, hasEq := strings.Cut(a, "=")
|
||||
for _, f := range flags {
|
||||
if name != f {
|
||||
continue
|
||||
}
|
||||
if hasEq {
|
||||
v = val
|
||||
} else if i+1 < len(args) {
|
||||
v = args[i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// cpusetThreads : sonde de conteneur, Linux seulement (fsys = la racine « / »).
|
||||
//
|
||||
// L'auto de llama.cpp se trompe quand le conteneur n'a droit qu'à une partie
|
||||
// de la machine :
|
||||
// - cpuset restreint (docker --cpuset-cpus) : common_cpu_get_num_math essaie
|
||||
// d'épingler un thread sur chaque CPU de l'hôte, échoue hors du cpuset et
|
||||
// se rabat sur TOUS les cœurs physiques de l'hôte (sysfs n'est pas cloisonné),
|
||||
// cœurs E compris ;
|
||||
// - quota CFS (docker --cpus) : invisible dans l'affinité, il étrangle des
|
||||
// threads qui attendent en boucle active — le pire cas.
|
||||
//
|
||||
// On compte alors comme llama.cpp (chaînes thread_siblings distinctes, cœurs E
|
||||
// de /sys/devices/cpu_atom/cpus écartés), mais sur les seuls CPU permis, plafonné
|
||||
// par le quota. On ne renvoie un nombre que s'il est PLUS PETIT que ce que le
|
||||
// moteur aurait pris seul ; à la moindre lecture ratée, rien (le moteur décide).
|
||||
func cpusetThreads(fsys fs.FS) cpuBudget {
|
||||
online, err := readCPUList(fsys, "sys/devices/system/cpu/online")
|
||||
if err != nil || len(online) == 0 {
|
||||
return cpuBudget{}
|
||||
}
|
||||
allowed, err := procAllowedCPUs(fsys)
|
||||
if err != nil {
|
||||
return cpuBudget{}
|
||||
}
|
||||
allowed = intersectCPUs(allowed, online)
|
||||
if len(allowed) == 0 {
|
||||
return cpuBudget{}
|
||||
}
|
||||
restricted := len(allowed) < len(online)
|
||||
quota := cgroupCPUQuota(fsys)
|
||||
if !restricted && quota == 0 {
|
||||
return cpuBudget{}
|
||||
}
|
||||
// Absent = CPU non hybride : aucun cœur E à écarter.
|
||||
atom, _ := readCPUList(fsys, "sys/devices/cpu_atom/cpus")
|
||||
|
||||
hostAll, err := physicalCores(fsys, online, nil)
|
||||
if err != nil {
|
||||
return cpuBudget{}
|
||||
}
|
||||
hostMath, err := physicalCores(fsys, online, atom)
|
||||
if err != nil {
|
||||
return cpuBudget{}
|
||||
}
|
||||
if hostMath == 0 {
|
||||
hostMath = hostAll
|
||||
}
|
||||
|
||||
var n, engine int
|
||||
var why string
|
||||
if restricted {
|
||||
engine = hostAll // épinglage refusé hors cpuset → repli sur tous les cœurs de l'hôte
|
||||
if n, err = physicalCores(fsys, allowed, atom); err != nil {
|
||||
return cpuBudget{}
|
||||
}
|
||||
if n == 0 { // conteneur cantonné aux cœurs E : on les prend, faute de mieux
|
||||
if n, err = physicalCores(fsys, allowed, nil); err != nil {
|
||||
return cpuBudget{}
|
||||
}
|
||||
}
|
||||
why = fmt.Sprintf("cpuset du conteneur, %d cœurs physiques permis sur %d", n, hostAll)
|
||||
if len(atom) > 0 {
|
||||
why += " (cœurs E écartés)"
|
||||
}
|
||||
} else {
|
||||
engine, n = hostMath, hostMath
|
||||
}
|
||||
if quota > 0 && quota < n {
|
||||
n = quota
|
||||
why = fmt.Sprintf("quota cgroup de %d CPU", quota)
|
||||
}
|
||||
if n <= 0 || n >= engine {
|
||||
return cpuBudget{}
|
||||
}
|
||||
return cpuBudget{N: n, Engine: engine, Why: why}
|
||||
}
|
||||
|
||||
// physicalCores compte les cœurs physiques parmi cpus comme le fait
|
||||
// common_cpu_get_num_physical_cores : une chaîne thread_siblings distincte par
|
||||
// cœur — juste aussi sur les machines multi-puces, où (package, core_id) peut
|
||||
// se répéter. Les CPU de exclude (cœurs E) ne comptent pas.
|
||||
func physicalCores(fsys fs.FS, cpus, exclude []int) (int, error) {
|
||||
skip := map[int]bool{}
|
||||
for _, c := range exclude {
|
||||
skip[c] = true
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range cpus {
|
||||
if skip[c] {
|
||||
continue
|
||||
}
|
||||
b, err := fs.ReadFile(fsys, fmt.Sprintf("sys/devices/system/cpu/cpu%d/topology/thread_siblings", c))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
seen[strings.TrimSpace(string(b))] = true
|
||||
}
|
||||
return len(seen), nil
|
||||
}
|
||||
|
||||
// procAllowedCPUs lit Cpus_allowed_list dans /proc/self/status : l'affinité
|
||||
// réelle du processus, cpuset du conteneur compris.
|
||||
func procAllowedCPUs(fsys fs.FS) ([]int, error) {
|
||||
b, err := fs.ReadFile(fsys, "proc/self/status")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "Cpus_allowed_list:"); ok {
|
||||
return parseCPUList(v)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("pas de Cpus_allowed_list")
|
||||
}
|
||||
|
||||
// cgroupCPUQuota renvoie le quota CPU du cgroup arrondi au CPU supérieur
|
||||
// (au moins 1), 0 s'il n'y en a pas ou s'il est illisible. cgroup v2
|
||||
// (cpu.max : « max 100000 » ou « 250000 100000 »), sinon v1 (cfs_quota_us,
|
||||
// -1 = illimité). Seul un conteneur voit son propre cgroup sous /sys/fs/cgroup.
|
||||
func cgroupCPUQuota(fsys fs.FS) int {
|
||||
if b, err := fs.ReadFile(fsys, "sys/fs/cgroup/cpu.max"); err == nil {
|
||||
f := strings.Fields(string(b))
|
||||
if len(f) == 0 || f[0] == "max" {
|
||||
return 0
|
||||
}
|
||||
period := "100000"
|
||||
if len(f) > 1 {
|
||||
period = f[1]
|
||||
}
|
||||
return ceilQuota(f[0], period)
|
||||
}
|
||||
for _, dir := range []string{"sys/fs/cgroup/cpu", "sys/fs/cgroup/cpu,cpuacct"} {
|
||||
q, err := fs.ReadFile(fsys, dir+"/cpu.cfs_quota_us")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
p, err := fs.ReadFile(fsys, dir+"/cpu.cfs_period_us")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return ceilQuota(strings.TrimSpace(string(q)), strings.TrimSpace(string(p)))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func ceilQuota(quota, period string) int {
|
||||
q, err1 := strconv.ParseInt(quota, 10, 64)
|
||||
p, err2 := strconv.ParseInt(period, 10, 64)
|
||||
if err1 != nil || err2 != nil || q <= 0 || p <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(max(1, (q+p-1)/p))
|
||||
}
|
||||
|
||||
// readCPUList lit un fichier au format liste du noyau (« 0-3,8-11 »).
|
||||
func readCPUList(fsys fs.FS, name string) ([]int, error) {
|
||||
b, err := fs.ReadFile(fsys, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseCPUList(string(b))
|
||||
}
|
||||
|
||||
// parseCPUList décode « 0-3,8-11 » en liste triée, sans doublon. Une liste
|
||||
// vide est valide (aucun CPU) ; une syntaxe inconnue est une erreur.
|
||||
func parseCPUList(s string) ([]int, error) {
|
||||
set := map[int]bool{}
|
||||
for _, part := range strings.Split(strings.TrimSpace(s), ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
lo, hi, isRange := strings.Cut(part, "-")
|
||||
a, err := strconv.Atoi(lo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := a
|
||||
if isRange {
|
||||
if b, err = strconv.Atoi(hi); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if a < 0 || b < a || b-a > 1<<16 {
|
||||
return nil, fmt.Errorf("liste de CPU invalide : %q", part)
|
||||
}
|
||||
for c := a; c <= b; c++ {
|
||||
set[c] = true
|
||||
}
|
||||
}
|
||||
out := make([]int, 0, len(set))
|
||||
for c := range set {
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func intersectCPUs(a, b []int) []int {
|
||||
in := map[int]bool{}
|
||||
for _, c := range b {
|
||||
in[c] = true
|
||||
}
|
||||
var out []int
|
||||
for _, c := range a {
|
||||
if in[c] {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
// cpuFS fabrique un /proc + /sys minimal : online, l'affinité du processus et,
|
||||
// pour chaque CPU en ligne, sa chaîne thread_siblings (siblings[cpu]).
|
||||
func cpuFS(online, allowed string, siblings map[int]string, extra map[string]string) fstest.MapFS {
|
||||
m := fstest.MapFS{
|
||||
"sys/devices/system/cpu/online": {Data: []byte(online + "\n")},
|
||||
"proc/self/status": {Data: []byte("Name:\tllama-server\nCpus_allowed:\tffff\nCpus_allowed_list:\t" + allowed + "\nMems_allowed:\t1\n")},
|
||||
}
|
||||
for c, s := range siblings {
|
||||
m[fmt.Sprintf("sys/devices/system/cpu/cpu%d/topology/thread_siblings", c)] = &fstest.MapFile{Data: []byte(s + "\n")}
|
||||
}
|
||||
for k, v := range extra {
|
||||
m[k] = &fstest.MapFile{Data: []byte(v)}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// smtPairs : n cœurs à deux threads, frères adjacents (0-1, 2-3…) — la
|
||||
// numérotation que suppose cpu_count_math_cpus.
|
||||
func smtPairs(cores, from int) map[int]string {
|
||||
m := map[int]string{}
|
||||
for i := 0; i < cores; i++ {
|
||||
s := fmt.Sprintf("core%d", from/2+i)
|
||||
m[from+2*i], m[from+2*i+1] = s, s
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// hybrid : 8 cœurs P à deux threads (CPU 0-15) + 8 cœurs E (CPU 16-23).
|
||||
func hybrid() map[int]string {
|
||||
m := smtPairs(8, 0)
|
||||
for c := 16; c < 24; c++ {
|
||||
m[c] = fmt.Sprintf("ecore%d", c)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestCPUSetThreads(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fs fstest.MapFS
|
||||
n int
|
||||
}{
|
||||
{"machine entière, sans quota : rien", cpuFS("0-15", "0-15", smtPairs(8, 0), nil), 0},
|
||||
{"cpuset de 8 threads SMT : 4 cœurs", cpuFS("0-15", "0-7", smtPairs(8, 0), nil), 4},
|
||||
{
|
||||
// L'exemple qui tournait mal : tout sauf cpu0 sur un Intel hybride.
|
||||
// Les cœurs E sont écartés, cpu1 partage son cœur avec cpu0 → 8, pas 15.
|
||||
"hybride, tout sauf cpu0 : 8 cœurs P",
|
||||
cpuFS("0-23", "1-23", hybrid(), map[string]string{"sys/devices/cpu_atom/cpus": "16-23\n"}), 8,
|
||||
},
|
||||
{
|
||||
"hybride, cantonné aux cœurs E : on les prend",
|
||||
cpuFS("0-23", "16-19", hybrid(), map[string]string{"sys/devices/cpu_atom/cpus": "16-23\n"}), 4,
|
||||
},
|
||||
{
|
||||
"quota v2 de 2,5 CPU : arrondi à 3",
|
||||
cpuFS("0-15", "0-15", smtPairs(8, 0), map[string]string{"sys/fs/cgroup/cpu.max": "250000 100000\n"}), 3,
|
||||
},
|
||||
{
|
||||
"quota v2 illimité : rien",
|
||||
cpuFS("0-15", "0-15", smtPairs(8, 0), map[string]string{"sys/fs/cgroup/cpu.max": "max 100000\n"}), 0,
|
||||
},
|
||||
{
|
||||
"quota plus large que les cœurs : rien",
|
||||
cpuFS("0-15", "0-15", smtPairs(8, 0), map[string]string{"sys/fs/cgroup/cpu.max": "1200000 100000\n"}), 0,
|
||||
},
|
||||
{
|
||||
"quota v1 de 2 CPU",
|
||||
cpuFS("0-15", "0-15", smtPairs(8, 0), map[string]string{
|
||||
"sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us": "200000\n",
|
||||
"sys/fs/cgroup/cpu,cpuacct/cpu.cfs_period_us": "100000\n"}), 2,
|
||||
},
|
||||
{
|
||||
"quota v1 illimité (-1) : rien",
|
||||
cpuFS("0-15", "0-15", smtPairs(8, 0), map[string]string{
|
||||
"sys/fs/cgroup/cpu/cpu.cfs_quota_us": "-1\n",
|
||||
"sys/fs/cgroup/cpu/cpu.cfs_period_us": "100000\n"}), 0,
|
||||
},
|
||||
{
|
||||
"cpuset ET quota : le plus petit",
|
||||
cpuFS("0-15", "0-7", smtPairs(8, 0), map[string]string{"sys/fs/cgroup/cpu.max": "200000 100000"}), 2,
|
||||
},
|
||||
{
|
||||
"topologie illisible : on ne devine pas",
|
||||
cpuFS("0-15", "0-7", map[int]string{0: "core0"}, nil), 0,
|
||||
},
|
||||
{"pas de /proc : rien", fstest.MapFS{"sys/devices/system/cpu/online": {Data: []byte("0-3")}}, 0},
|
||||
{"liste illisible : rien", cpuFS("0-15", "0-x", smtPairs(8, 0), nil), 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := cpusetThreads(c.fs)
|
||||
if got.N != c.n {
|
||||
t.Fatalf("N = %d (%+v), want %d", got.N, got, c.n)
|
||||
}
|
||||
if got.N > 0 && (got.Why == "" || got.Engine <= got.N) {
|
||||
t.Fatalf("budget incomplet : %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCPUList(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in string
|
||||
want []int
|
||||
ok bool
|
||||
}{
|
||||
{"0-3,8-9\n", []int{0, 1, 2, 3, 8, 9}, true},
|
||||
{" 5 ", []int{5}, true},
|
||||
{"2,0-2", []int{0, 1, 2}, true},
|
||||
{"", []int{}, true},
|
||||
{"3-1", nil, false},
|
||||
{"a", nil, false},
|
||||
} {
|
||||
got, err := parseCPUList(c.in)
|
||||
if (err == nil) != c.ok || (c.ok && !reflect.DeepEqual(got, c.want)) {
|
||||
t.Errorf("parseCPUList(%q) = %v, %v ; want %v", c.in, got, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadCount(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in string
|
||||
n int
|
||||
note bool
|
||||
}{
|
||||
{"", 0, false}, {"0", 0, false}, {" 8 ", 8, false}, {"auto", 0, true}, {"-1", 0, true},
|
||||
} {
|
||||
n, note := threadCount("THREADS", c.in)
|
||||
if n != c.n || (note != "") != c.note {
|
||||
t.Errorf("threadCount(%q) = %d, %q", c.in, n, note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagValue(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{nil, ""},
|
||||
{[]string{"-t", "6"}, "6"},
|
||||
{[]string{"--threads=7"}, "7"},
|
||||
{[]string{"-t", "6", "--threads", "8"}, "8"}, // la dernière gagne, comme dans le moteur
|
||||
{[]string{"-tb", "9"}, ""}, // -tb n'est pas -t
|
||||
{[]string{"-t"}, ""}, // drapeau orphelin
|
||||
} {
|
||||
if got := flagValue(c.args, "-t", "--threads"); got != c.want {
|
||||
t.Errorf("flagValue(%q) = %q, want %q", c.args, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,392 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// L'optimiseur côté web et en ligne de commande.
|
||||
//
|
||||
// POST /api/tune lance (202), 409 si le moteur est occupé
|
||||
// GET /api/tune/status phase, essais, ETA ; puis le résultat
|
||||
// POST /api/tune/cancel annule (l'essai en cours est arrêté, le moteur relancé)
|
||||
// POST /api/tune/apply {target:"copy"|"preset"} — SEULEMENT sur un clic
|
||||
// GET /api/tune/last dernier résultat d'un preset, et si le moteur a changé depuis
|
||||
//
|
||||
// Rien ne tourne de soi-même : ni au démarrage, ni après une mise à jour du
|
||||
// moteur (l'interface PROPOSE alors de relancer).
|
||||
|
||||
// tuneJob : l'optimisation en cours ou la dernière (une seule à la fois).
|
||||
var tuneJob struct {
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
tracker *tuneTracker
|
||||
cancel context.CancelFunc
|
||||
result *tuneResult
|
||||
err string
|
||||
finished time.Time
|
||||
// application en cours (preset réécrit, redémarrage, sonde)
|
||||
applying bool
|
||||
applyLog []string
|
||||
applyMsg string
|
||||
applyErr string
|
||||
}
|
||||
|
||||
// tuneRunner est runTune, remplaçable dans les tests.
|
||||
var tuneRunner = runTune
|
||||
|
||||
func tuneStart(opts tuneOpts) (int, error) {
|
||||
if err := tunePreflight(context.Background(), true); err != nil {
|
||||
code := http.StatusConflict
|
||||
if externalActive() {
|
||||
code = http.StatusBadRequest
|
||||
}
|
||||
return code, err
|
||||
}
|
||||
tuneJob.mu.Lock()
|
||||
defer tuneJob.mu.Unlock()
|
||||
if tuneJob.running || tuneJob.applying {
|
||||
return http.StatusConflict, errTuneBusy
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// Le verrou de la discussion : un message, une tâche, une compaction
|
||||
// reçoivent « optimisation en cours » au lieu d'attendre un moteur absent.
|
||||
// Le bouton stop du chat annule l'optimisation, comme un bench.
|
||||
release, err := conv.measureLease(cancel, errTuneBusy)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return http.StatusConflict, conv.busyReason()
|
||||
}
|
||||
tr := &tuneTracker{}
|
||||
tuneJob.running, tuneJob.tracker, tuneJob.cancel = true, tr, cancel
|
||||
tuneJob.result, tuneJob.err = nil, ""
|
||||
tuneJob.applyLog, tuneJob.applyMsg, tuneJob.applyErr = nil, "", ""
|
||||
opts.Via = "web"
|
||||
go func() {
|
||||
defer cancel()
|
||||
res, err := tuneRunner(ctx, opts, tr)
|
||||
tr.mu.Lock()
|
||||
tr.ended = time.Now()
|
||||
tr.mu.Unlock()
|
||||
release()
|
||||
tuneJob.mu.Lock()
|
||||
defer tuneJob.mu.Unlock()
|
||||
tuneJob.running, tuneJob.cancel, tuneJob.finished = false, nil, time.Now()
|
||||
tuneJob.result = res
|
||||
if err != nil {
|
||||
tuneJob.err = err.Error()
|
||||
if ctx.Err() != nil {
|
||||
tuneJob.err = "optimisation annulée"
|
||||
}
|
||||
}
|
||||
}()
|
||||
return http.StatusAccepted, nil
|
||||
}
|
||||
|
||||
func tuneStatus() map[string]any {
|
||||
tuneJob.mu.Lock()
|
||||
defer tuneJob.mu.Unlock()
|
||||
st := map[string]any{"ok": true, "running": tuneJob.running, "applying": tuneJob.applying}
|
||||
if tuneJob.tracker != nil {
|
||||
for k, v := range tuneJob.tracker.snapshot() {
|
||||
st[k] = v
|
||||
}
|
||||
}
|
||||
if !tuneJob.running {
|
||||
if tuneJob.result != nil {
|
||||
st["result"] = tuneJob.result
|
||||
}
|
||||
if tuneJob.err != "" {
|
||||
st["error"] = tuneJob.err
|
||||
}
|
||||
}
|
||||
if len(tuneJob.applyLog) > 0 || tuneJob.applyMsg != "" || tuneJob.applyErr != "" {
|
||||
st["apply"] = map[string]any{"log": tuneJob.applyLog, "message": tuneJob.applyMsg, "error": tuneJob.applyErr}
|
||||
}
|
||||
// Une optimisation lancée ailleurs (« loki tune » en ligne de commande).
|
||||
if o, busy := tuneActive(); busy && !tuneJob.running && !tuneJob.applying {
|
||||
st["elsewhere"] = o.Via
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func handleTune(w http.ResponseWriter, r *http.Request) {
|
||||
if !postOnly(w, r) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Placement bool `json:"placement"`
|
||||
OptIn bool `json:"optin"`
|
||||
BudgetMin int `json:"budget_min"`
|
||||
Stages []string `json:"stages"`
|
||||
}
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body)
|
||||
opts := tuneOpts{Placement: body.Placement, OptIn: body.OptIn, Stages: body.Stages}
|
||||
if body.BudgetMin > 0 {
|
||||
opts.Budget = time.Duration(body.BudgetMin) * time.Minute
|
||||
}
|
||||
code, err := tuneStart(opts)
|
||||
if err != nil {
|
||||
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, code, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func handleTuneStatus(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, tuneStatus()) }
|
||||
|
||||
func handleTuneCancel(w http.ResponseWriter, r *http.Request) {
|
||||
if !postOnly(w, r) {
|
||||
return
|
||||
}
|
||||
tuneJob.mu.Lock()
|
||||
ok := tuneJob.running && tuneJob.cancel != nil
|
||||
if ok {
|
||||
tuneJob.cancel()
|
||||
}
|
||||
tuneJob.mu.Unlock()
|
||||
sendJSON(w, 200, map[string]any{"ok": ok})
|
||||
}
|
||||
|
||||
// handleTuneApply écrit le dernier résultat, sur le clic de l'utilisateur
|
||||
// (l'interface a montré le diff et demandé confirmation). Copie : immédiat.
|
||||
// Preset : en arrière-plan (redémarrage, sonde, retour arrière si besoin).
|
||||
func handleTuneApply(w http.ResponseWriter, r *http.Request) {
|
||||
if !postOnly(w, r) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Target string `json:"target"`
|
||||
}
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body)
|
||||
tuneJob.mu.Lock()
|
||||
res := tuneJob.result
|
||||
busy := tuneJob.running || tuneJob.applying
|
||||
tuneJob.mu.Unlock()
|
||||
if busy {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": errTuneBusy.Error()})
|
||||
return
|
||||
}
|
||||
if res == nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "aucun résultat à appliquer"})
|
||||
return
|
||||
}
|
||||
if body.Target == tuneApplyCopy {
|
||||
msg, err := tuneApply(context.Background(), res, tuneApplyCopy, func(string) {})
|
||||
if err != nil {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "message": msg})
|
||||
return
|
||||
}
|
||||
if body.Target != tuneApplyPreset {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "cible inconnue"})
|
||||
return
|
||||
}
|
||||
if _, _, err := tunePlanApply(res); err != nil {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if conv.isGenerating() {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": conv.busyReason().Error()})
|
||||
return
|
||||
}
|
||||
// Vérifié et posé d'un seul tenant : deux clics rapprochés passaient tous
|
||||
// deux le test du début, et le perdant remettait applying à faux pendant
|
||||
// que le premier écrivait encore.
|
||||
tuneJob.mu.Lock()
|
||||
if tuneJob.running || tuneJob.applying {
|
||||
tuneJob.mu.Unlock()
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": errTuneBusy.Error()})
|
||||
return
|
||||
}
|
||||
tuneJob.applying, tuneJob.applyLog, tuneJob.applyMsg, tuneJob.applyErr = true, nil, "", ""
|
||||
tuneJob.mu.Unlock()
|
||||
go func() {
|
||||
say := func(s string) {
|
||||
tuneJob.mu.Lock()
|
||||
tuneJob.applyLog = append(tuneJob.applyLog, s)
|
||||
tuneJob.mu.Unlock()
|
||||
}
|
||||
msg, err := tuneApply(context.Background(), res, tuneApplyPreset, say)
|
||||
tuneJob.mu.Lock()
|
||||
defer tuneJob.mu.Unlock()
|
||||
tuneJob.applying = false
|
||||
tuneJob.applyMsg = msg
|
||||
if err != nil {
|
||||
tuneJob.applyErr = err.Error()
|
||||
}
|
||||
}()
|
||||
sendJSON(w, 202, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleTuneLast : le dernier résultat enregistré d'un preset, et si le moteur
|
||||
// a changé depuis (build différent) — l'interface propose alors de relancer.
|
||||
func handleTuneLast(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
res, ok := loadTuneResult(id)
|
||||
if !ok {
|
||||
sendJSON(w, 200, map[string]any{"ok": false})
|
||||
return
|
||||
}
|
||||
out := map[string]any{"ok": true, "result": res}
|
||||
if content, err := ReadPreset(id); err == nil {
|
||||
out["preset_changed"] = presetFingerprint([]byte(content)) != res.PresetFP
|
||||
}
|
||||
if bin := tuneEngineBin(ReadConfig()); bin != "" && res.EngineBuild > 0 {
|
||||
if b := engineBuildCached(bin); b > 0 && b != res.EngineBuild {
|
||||
out["engine_changed"] = fmt.Sprintf("b%d → b%d", res.EngineBuild, b)
|
||||
}
|
||||
}
|
||||
sendJSON(w, 200, out)
|
||||
}
|
||||
|
||||
// tuneEngineBin : le binaire du moteur, résolu comme cmdServe le fait.
|
||||
func tuneEngineBin(cfg map[string]string) string {
|
||||
bin := strings.TrimSpace(cfg["BIN"])
|
||||
if bin == "" {
|
||||
return ""
|
||||
}
|
||||
if !filepath.IsAbs(bin) {
|
||||
bin = filepath.Join(LokiHome(), bin)
|
||||
}
|
||||
return prebuiltResolveBin(bin)
|
||||
}
|
||||
|
||||
// --- ligne de commande -------------------------------------------------------------
|
||||
|
||||
// cmdTune : « loki tune [--placement] [--opt-in] [--budget MIN] [--stages …] ». Mesure, montre,
|
||||
// puis DEMANDE où écrire le résultat — rien n'est écrit sans réponse. Comme
|
||||
// « loki bench », ce processus ne voit pas la discussion du processus web :
|
||||
// seule la vérification /slots (moteur occupé) protège un tour en cours ; une
|
||||
// fois le verrou pris, le processus web refuse tout nouveau tour. Le bouton de
|
||||
// l'interface, lui, voit tout.
|
||||
func cmdTune(args []string) error {
|
||||
fs := flag.NewFlagSet("tune", flag.ContinueOnError)
|
||||
placement := fs.Bool("placement", false, "essayer --fit à la place du placement manuel (réécrit EXTRA_ARGS)")
|
||||
optIn := fs.Bool("opt-in", false, "essayer aussi SPEC, CUDA_GRAPH_OPT et --backend-sampling")
|
||||
budget := fs.Int("budget", int(tuneDefaultBudget/time.Minute), "budget en minutes")
|
||||
stages := fs.String("stages", "", "étapes de base, séparées par des virgules : marges,lots,delestage,threads,files (vide = toutes)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if err := tunePreflight(ctx, false); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("\n %s — essais sur un moteur privé (127.0.0.1), config.env jamais modifié.\n", cyan("Optimiseur sans perte"))
|
||||
fmt.Println(dim(" Le chat et les tâches attendent pendant la mesure ; Ctrl-C annule (le moteur est relancé)."))
|
||||
fmt.Println()
|
||||
// Ctrl-C annule proprement : l'essai est arrêté, le vrai moteur relancé.
|
||||
// Le terminal fermé (SIGHUP ; CTRL_CLOSE_EVENT, livré en SIGTERM sous
|
||||
// Windows) ou un kill aussi : sans ça, le processus mourait sans ses defers,
|
||||
// le vrai moteur restait arrêté et l'essai gardait la VRAM.
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
|
||||
defer signal.Stop(sig)
|
||||
go func() {
|
||||
if _, ok := <-sig; ok {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
tr := &tuneTracker{print: true}
|
||||
var only []string
|
||||
for _, s := range strings.Split(*stages, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
only = append(only, s)
|
||||
}
|
||||
}
|
||||
res, err := runTune(ctx, tuneOpts{Placement: *placement, OptIn: *optIn,
|
||||
Budget: time.Duration(*budget) * time.Minute, Via: "cli", Stages: only}, tr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Les essais sont finis : Ctrl-C reprend son sens ordinaire aux questions.
|
||||
signal.Stop(sig)
|
||||
printTuneResult(res)
|
||||
if len(res.Set) == 0 {
|
||||
return nil
|
||||
}
|
||||
in := bufio.NewScanner(os.Stdin)
|
||||
ask := func(q string) string {
|
||||
fmt.Print(q)
|
||||
if !in.Scan() {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(in.Text()))
|
||||
}
|
||||
switch ask(" Enregistrer ? [c] dans une copie du preset · [p] dans « " + res.PresetName + " » (redémarre le moteur) · [N] non : ") {
|
||||
case "c":
|
||||
msg, err := tuneApply(context.Background(), res, tuneApplyCopy, func(string) {})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(green("[ok] ") + msg)
|
||||
case "p":
|
||||
if res.Placement && ask(" Le placement réécrit EXTRA_ARGS (voir ci-dessus). Confirmer ? [o/N] ") != "o" {
|
||||
fmt.Println(dim(" rien n'a été écrit"))
|
||||
return nil
|
||||
}
|
||||
msg, err := tuneApply(context.Background(), res, tuneApplyPreset, func(s string) { fmt.Println(" " + s) })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(green("[ok] ") + msg)
|
||||
default:
|
||||
fmt.Println(dim(" rien n'a été écrit (le résultat reste visible dans l'éditeur du preset)"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printTuneResult(res *tuneResult) {
|
||||
fmt.Println()
|
||||
row := func(t tuneTrial) {
|
||||
turn := "—"
|
||||
if t.TurnSec > 0 {
|
||||
turn = fmt.Sprintf("%.1f s", t.TurnSec)
|
||||
}
|
||||
why := t.Why
|
||||
if t.Headroom >= 0 {
|
||||
why += fmt.Sprintf(" · %d Mio libres", t.Headroom)
|
||||
}
|
||||
fmt.Printf(" %-10s %-44s %8s %-8s %s\n", t.Stage, t.Label, turn, t.Status, strings.TrimPrefix(why, " · "))
|
||||
}
|
||||
fmt.Printf(" %s (K=%.0f jetons lus, G=%.0f écrits, %.0f %% à froid à %d jetons — %s)\n",
|
||||
cyan("Durée d'un tour type"), res.Weights.K, res.Weights.G, res.Weights.FCold*100, res.Depth, res.Weights.From)
|
||||
row(res.Baseline)
|
||||
for _, t := range res.Trials {
|
||||
row(t)
|
||||
}
|
||||
for _, s := range res.Skipped {
|
||||
fmt.Println(dim(" sauté — " + s))
|
||||
}
|
||||
for _, n := range res.Notes {
|
||||
fmt.Println(yellow(" ! ") + n)
|
||||
}
|
||||
if res.Partial != "" {
|
||||
fmt.Println(yellow(" ! résultat partiel : ") + res.Partial)
|
||||
}
|
||||
fmt.Println()
|
||||
if len(res.Set) == 0 {
|
||||
fmt.Println(" La configuration actuelle reste la meilleure : rien à changer.")
|
||||
return
|
||||
}
|
||||
fmt.Printf(" %s %s : −%.1f %% par tour\n", green("Meilleur :"), res.Best, res.Gain*100)
|
||||
for _, c := range res.Changes {
|
||||
fmt.Println(" " + c)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Verrou de l'optimiseur (loki tune, bouton « Optimiser »).
|
||||
//
|
||||
// Pendant une optimisation, la VRAM appartient au moteur d'ESSAI : le vrai
|
||||
// moteur est arrêté, et le relancer à côté (bascule de preset, enregistrement du
|
||||
// preset actif, choix des GPU, mise à jour du moteur, « recharger » la VRAM…)
|
||||
// chargerait deux modèles sur des cartes qui n'en tiennent qu'un — un OOM certain,
|
||||
// et une mesure faussée. Un drapeau en mémoire ne suffit pas : « loki tune » en
|
||||
// ligne de commande et le processus web sont deux processus distincts. Le verrou
|
||||
// est donc un FICHIER de LOKI_HOME, créé de façon exclusive, que tout le monde
|
||||
// lit :
|
||||
//
|
||||
// - serviceAction("start"/"restart") le consulte sur toutes les plateformes —
|
||||
// aucun démarrage du moteur ne passe à côté ;
|
||||
// - le chat, les tâches planifiées (qui attendent), les jobs du mode code, les
|
||||
// clients /v1, le bench, la bascule et l'enregistrement de preset refusent
|
||||
// avec une phrase claire.
|
||||
//
|
||||
// Le fichier porte l'identité du processus qui le tient (PID, instant de
|
||||
// démarrage, binaire) et celle de l'essai en cours. Un verrou dont le
|
||||
// propriétaire est mort (Loki redémarré en plein essai) est périmé : avant tout
|
||||
// démarrage du moteur, l'essai orphelin est arrêté — seulement si PID, instant
|
||||
// de démarrage ET binaire concordent, jamais sur la foi d'un PID recyclé — puis
|
||||
// le fichier est retiré.
|
||||
|
||||
const tuneLockName = "tune.lock"
|
||||
|
||||
// tuneLockFreshness : un verrou illisible plus jeune que ça est en cours
|
||||
// d'écriture, pas abandonné.
|
||||
const tuneLockFreshness = 10 * time.Second
|
||||
|
||||
var errTuneBusy = errors.New("optimisation en cours (loki tune) : le moteur est réservé aux essais — " +
|
||||
"attends la fin ou annule-la")
|
||||
|
||||
// tuneProc : un processus désigné sans ambiguïté.
|
||||
type tuneProc struct {
|
||||
PID int `json:"pid"`
|
||||
Start string `json:"start,omitempty"` // procIdentity ; vide = illisible au moment de l'écrire
|
||||
Exes []string `json:"exes,omitempty"` // binaires admis (un « loki serve » devient llama-server par exec)
|
||||
}
|
||||
|
||||
// tuneOwner : le contenu du verrou.
|
||||
type tuneOwner struct {
|
||||
Owner tuneProc `json:"owner"`
|
||||
Via string `json:"via"` // « web » ou « cli »
|
||||
Phase string `json:"phase"` // « essais » ou « application »
|
||||
Since int64 `json:"since"`
|
||||
Trial *tuneProc `json:"trial,omitempty"`
|
||||
// MainWasActive : le vrai moteur tournait avant l'optimisation. Un verrou
|
||||
// périmé trouvé au démarrage du processus web le relance.
|
||||
MainWasActive bool `json:"main_was_active"`
|
||||
// Application en cours (phase « application ») : la version d'avant du
|
||||
// preset, sauvée avant toute écriture. Un verrou périmé dans cette phase
|
||||
// veut dire que l'application n'a jamais été vérifiée : elle est défaite.
|
||||
Backup string `json:"backup,omitempty"`
|
||||
PresetID string `json:"preset_id,omitempty"`
|
||||
PresetName string `json:"preset_name,omitempty"`
|
||||
}
|
||||
|
||||
func tuneLockPath() string { return filepath.Join(LokiHome(), tuneLockName) }
|
||||
|
||||
// tuneProcAlive dit si p désigne encore le MÊME processus. Variable pour les
|
||||
// tests.
|
||||
var tuneProcAlive = func(p tuneProc) bool {
|
||||
start, exe, ok := procIdentity(p.PID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if p.Start != "" && start != p.Start {
|
||||
return false
|
||||
}
|
||||
if exe == "" || len(p.Exes) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, e := range p.Exes {
|
||||
if sameExe(e, exe) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sameExe compare deux binaires par leur nom de fichier (sans .exe, sans
|
||||
// casse) : /proc/<pid>/exe résout les liens symboliques, ps n'en donne parfois
|
||||
// que le nom.
|
||||
func sameExe(a, b string) bool {
|
||||
norm := func(s string) string {
|
||||
s = strings.TrimSuffix(strings.TrimSpace(s), " (deleted)") // binaire remplacé (Linux)
|
||||
s = strings.ToLower(filepath.Base(strings.ReplaceAll(s, `\`, "/")))
|
||||
return strings.TrimSuffix(s, ".exe")
|
||||
}
|
||||
return a != "" && b != "" && norm(a) == norm(b)
|
||||
}
|
||||
|
||||
// selfProc : ce processus, tel que le verrou le note.
|
||||
func selfProc() tuneProc {
|
||||
p := tuneProc{PID: os.Getpid()}
|
||||
if start, exe, ok := procIdentity(p.PID); ok {
|
||||
p.Start = start
|
||||
if exe != "" {
|
||||
p.Exes = []string{exe}
|
||||
}
|
||||
}
|
||||
if self, err := os.Executable(); err == nil && len(p.Exes) == 0 {
|
||||
p.Exes = []string{self}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func readTuneOwner(path string) (tuneOwner, bool) {
|
||||
var o tuneOwner
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil || json.Unmarshal(b, &o) != nil || o.Owner.PID <= 0 {
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
return o, true
|
||||
}
|
||||
|
||||
// tuneActive : une optimisation tient-elle le moteur, dans ce processus ou dans
|
||||
// un autre ? Un verrou périmé (propriétaire mort) ne compte pas.
|
||||
func tuneActive() (tuneOwner, bool) {
|
||||
o, ok := readTuneOwner(tuneLockPath())
|
||||
if !ok {
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
return o, tuneOwnerAlive(o)
|
||||
}
|
||||
|
||||
// tuneHeld : verrous pris par CE processus et pas encore rendus (tuneHeldKey).
|
||||
var tuneHeld sync.Map
|
||||
|
||||
func tuneHeldKey(o tuneOwner) string { return fmt.Sprintf("%s|%d", o.Via, o.Since) }
|
||||
|
||||
// tuneOwnerAlive : le propriétaire d'un verrou vit-il encore ? Un verrou qui
|
||||
// porte NOTRE PID sans être l'un des nôtres vient d'un processus mort dont le
|
||||
// PID nous est échu (redémarrage) : périmé, quoi que dise son identité.
|
||||
func tuneOwnerAlive(o tuneOwner) bool {
|
||||
if o.Owner.PID == os.Getpid() {
|
||||
if _, ours := tuneHeld.Load(tuneHeldKey(o)); !ours {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return tuneProcAlive(o.Owner)
|
||||
}
|
||||
|
||||
// tuneGuard : nil si rien ne tient le moteur, errTuneBusy sinon.
|
||||
func tuneGuard() error {
|
||||
if _, busy := tuneActive(); busy {
|
||||
return errTuneBusy
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tuneDenyHTTP répond 409 et rend true quand une optimisation tient le moteur.
|
||||
func tuneDenyHTTP(w http.ResponseWriter) bool {
|
||||
if err := tuneGuard(); err != nil {
|
||||
sendJSON(w, http.StatusConflict, map[string]any{"ok": false, "tuning": true, "error": err.Error()})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// tuneLock : le verrou tenu par CE processus.
|
||||
type tuneLock struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
owner tuneOwner
|
||||
held bool // inscrit dans tuneHeld, jusqu'à release
|
||||
}
|
||||
|
||||
// tuneLockAcquire prend le verrou, ou dit qui le tient. Un verrou périmé est
|
||||
// d'abord écarté (tuneReapStale), essai orphelin compris.
|
||||
func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
|
||||
path := tuneLockPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l := &tuneLock{path: path, owner: tuneOwner{Owner: selfProc(), Via: via, Phase: "essais",
|
||||
Since: time.Now().Unix(), MainWasActive: mainActive}}
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
if err == nil {
|
||||
b, _ := json.Marshal(l.owner)
|
||||
_, werr := f.Write(b)
|
||||
cerr := f.Close()
|
||||
if werr != nil || cerr != nil {
|
||||
_ = os.Remove(path)
|
||||
return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr))
|
||||
}
|
||||
l.held = true
|
||||
tuneHeld.Store(tuneHeldKey(l.owner), true)
|
||||
return l, nil
|
||||
}
|
||||
if !os.IsExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
if o, busy := tuneActive(); busy {
|
||||
return nil, fmt.Errorf("une optimisation tourne déjà (%s, PID %d, depuis %s)", o.Via, o.Owner.PID,
|
||||
time.Unix(o.Since, 0).Format("15:04"))
|
||||
}
|
||||
tuneReapStale()
|
||||
}
|
||||
return nil, errors.New("verrou de l'optimiseur indisponible : réessaie")
|
||||
}
|
||||
|
||||
// write réécrit le verrou d'un bloc (fichier voisin puis renommage) : un
|
||||
// lecteur ne voit jamais un JSON à moitié écrit.
|
||||
func (l *tuneLock) write() error {
|
||||
b, err := json.Marshal(l.owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := l.path + ".tmp-" + strconv.Itoa(os.Getpid())
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
// Windows refuse de remplacer un fichier qu'un autre processus lit à cet
|
||||
// instant (tuneGuard d'une requête) : on réessaie un peu avant de renoncer.
|
||||
for i := 0; ; i++ {
|
||||
err := os.Rename(tmp, l.path)
|
||||
if err == nil || i == 4 {
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
return err
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// setTrial note l'essai en cours (nil : aucun), AVANT qu'il ne charge quoi que
|
||||
// ce soit d'important : c'est ce qui permet à un Loki redémarré de l'arrêter.
|
||||
func (l *tuneLock) setTrial(p *tuneProc) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.owner.Trial = p
|
||||
_ = l.write()
|
||||
}
|
||||
|
||||
func (l *tuneLock) setPhase(phase string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.owner.Phase = phase
|
||||
_ = l.write()
|
||||
}
|
||||
|
||||
// setBackup note la sauvegarde du preset avant son écriture (tuneApply).
|
||||
func (l *tuneLock) setBackup(backup, id, name string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = backup, id, name
|
||||
_ = l.write()
|
||||
}
|
||||
|
||||
// release rend le verrou — seulement s'il est encore le nôtre.
|
||||
func (l *tuneLock) release() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.held {
|
||||
l.held = false
|
||||
tuneHeld.Delete(tuneHeldKey(l.owner))
|
||||
}
|
||||
if o, ok := readTuneOwner(l.path); ok && o.Owner.PID == l.owner.Owner.PID && o.Owner.Start == l.owner.Owner.Start {
|
||||
_ = os.Remove(l.path)
|
||||
}
|
||||
}
|
||||
|
||||
// tuneReapStale écarte un verrou dont le propriétaire est mort : l'essai qu'il
|
||||
// désigne est arrêté s'il est encore là (identité complète vérifiée), puis le
|
||||
// fichier disparaît. Rend MainWasActive du verrou écarté (false sans verrou
|
||||
// périmé). Le renommage d'abord : de deux processus qui trouvent le même verrou
|
||||
// périmé, un seul l'emporte, et aucun ne retire un verrou neuf posé entre-temps.
|
||||
func tuneReapStale() (mainWasActive bool) {
|
||||
path := tuneLockPath()
|
||||
o, ok := readTuneOwner(path)
|
||||
if !ok {
|
||||
// Illisible (écriture interrompue) : périmé lui aussi, s'il est là —
|
||||
// mais pas tout juste créé : entre sa création exclusive et l'écriture de
|
||||
// son contenu, un verrou neuf est vide quelques instants, et le retirer
|
||||
// laisserait passer une seconde optimisation ou un démarrage du moteur.
|
||||
if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > tuneLockFreshness {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
return false
|
||||
}
|
||||
if tuneOwnerAlive(o) {
|
||||
return false
|
||||
}
|
||||
stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano())
|
||||
if os.Rename(path, stale) != nil {
|
||||
return false
|
||||
}
|
||||
defer os.Remove(stale)
|
||||
if o.Trial != nil && tuneProcAlive(*o.Trial) {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID)
|
||||
tuneKillTree(o.Trial.PID, nil)
|
||||
}
|
||||
tuneUndoApply(o)
|
||||
return o.MainWasActive
|
||||
}
|
||||
|
||||
// tuneUndoApply : une application interrompue avant sa vérification (Loki tué
|
||||
// pendant la sonde) remet la version d'avant du preset — c'est ce que la
|
||||
// vérification aurait fait en échouant. Sans sauvegarde notée, rien.
|
||||
func tuneUndoApply(o tuneOwner) {
|
||||
if o.Phase != "application" || o.Backup == "" || o.PresetID == "" {
|
||||
return
|
||||
}
|
||||
b, err := os.ReadFile(o.Backup)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] application interrompue : sauvegarde illisible (%v)\n", err)
|
||||
return
|
||||
}
|
||||
if _, _, err := SavePresetApplying(o.PresetID, o.PresetName, string(b)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] application interrompue : retour impossible (%v) — l'ancienne version est dans %s\n", err, o.Backup)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] application interrompue avant sa vérification : ancienne version du preset rétablie\n")
|
||||
}
|
||||
|
||||
// tuneRecoverAtBoot : au démarrage du processus web, une optimisation
|
||||
// interrompue (Loki tué en plein essai) a laissé le vrai moteur arrêté et peut-
|
||||
// être un essai en VRAM. On arrête l'essai, puis on relance le moteur s'il
|
||||
// tournait avant — ce que l'optimisation aurait fait en finissant.
|
||||
func tuneRecoverAtBoot() {
|
||||
if _, err := os.Stat(tuneLockPath()); err != nil {
|
||||
return
|
||||
}
|
||||
if !tuneReapStale() || externalActive() || serviceIsActive() {
|
||||
return
|
||||
}
|
||||
if err := preflightEngine(); err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Println(dim("[loki tune] optimisation interrompue : relance du moteur"))
|
||||
_ = serviceActionOS("start")
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,834 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Aide d'un moteur récent, réduite à ce que lisent les tests de capacité.
|
||||
const tuneTestHelp = `-ngl, --gpu-layers, --n-gpu-layers N max. number of layers to store in VRAM, either an exact number, 'auto', or 'all'
|
||||
-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
|
||||
--spec-type [none,draft-simple,draft-mtp,ngram-mod]
|
||||
--spec-draft-n-max N
|
||||
-bs, --backend-sampling
|
||||
--load-mode {auto,none,mmap,mlock,mmap+mlock,dio}
|
||||
`
|
||||
|
||||
// Le découpage avec positions doit donner exactement les jetons de splitArgs :
|
||||
// c'est ce qui garantit qu'une réécriture ne touche qu'aux jetons visés.
|
||||
func TestTuneArgSpansMatchSplitArgs(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"", " ", "-b 2048", `--chat-template-file "/mes modèles/tpl.jinja" -ub 512`,
|
||||
`-ot 'blk\.(1[0-9])\.ffn_.*_exps\.=CPU' --n-cpu-moe 40`, `"" -x`, `""x y`, `a""b`, `'a b'c "d"`,
|
||||
"--cache-type-k q8_0\t--flash-attn on\n-t 8", `"non fermé -b 4`, `x"y z"w -ub=1024`,
|
||||
} {
|
||||
var got []string
|
||||
for _, sp := range argSpans(s) {
|
||||
got = append(got, sp.val)
|
||||
}
|
||||
want := splitArgs(s)
|
||||
if len(got) == 0 && len(want) == 0 {
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%q : argSpans %q, splitArgs %q", s, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Les jetons hors liste blanche ressortent à l'octet près, guillemets compris,
|
||||
// quelle que soit la réécriture.
|
||||
func TestTuneExtraEditsKeepOtherTokens(t *testing.T) {
|
||||
keep := []string{"--cache-type-k q8_0", "--flash-attn on", `--chat-template-file "/a b/t.jinja"`, "-ctv q8_0"}
|
||||
extra := `-ot "blk\.([0-9]+)\.ffn_.*_exps\.=CPU,per_layer_token_embd=CPU" --cache-type-k q8_0 --n-cpu-moe 40 ` +
|
||||
`--flash-attn on -ub 512 --chat-template-file "/a b/t.jinja" -ts 0.9,0.1 -ctv q8_0 -cmoe`
|
||||
edits := map[string]string{
|
||||
"set -ub": tuneExtraSet(extra, knobUBatch.flags, "2048"),
|
||||
"drop -ub": tuneExtraSet(extra, knobUBatch.flags, ""),
|
||||
"add -t": tuneExtraSet(extra, knobThreads.flags, "7"),
|
||||
"drop -ts": tuneExtraDrop(extra, 1, "-ts", "--tensor-split"),
|
||||
"add switch": tuneExtraAddSwitch(extra, "--backend-sampling"),
|
||||
"drop expert": func() string { s, _ := tuneExtraDropExperts(extra); return s }(),
|
||||
}
|
||||
for name, out := range edits {
|
||||
for _, k := range keep {
|
||||
if !strings.Contains(out, k) {
|
||||
t.Errorf("%s : %q perdu dans %q", name, k, out)
|
||||
}
|
||||
}
|
||||
if err := tuneCfgCheck(map[string]string{"EXTRA_ARGS": extra}, map[string]string{"EXTRA_ARGS": out}, true); err != nil {
|
||||
t.Errorf("%s : %v (%q)", name, err, out)
|
||||
}
|
||||
}
|
||||
if out := edits["set -ub"]; !strings.Contains(out, "-ub 2048") || strings.Contains(out, "-ub 512") {
|
||||
t.Errorf("-ub non réglé sur place : %q", out)
|
||||
}
|
||||
out, removed := tuneExtraDropExperts(extra)
|
||||
if strings.Contains(out, "exps") || strings.Contains(out, "--n-cpu-moe") || strings.Contains(out, "-cmoe") {
|
||||
t.Errorf("experts encore placés à la main : %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "-ot per_layer_token_embd=CPU") || len(removed) != 3 {
|
||||
t.Errorf("élément non expert du -ot perdu, ou retraits mal comptés : %q %q", out, removed)
|
||||
}
|
||||
if got := tuneExtraSet("-ub=512 -b 4096", knobUBatch.flags, "1024"); got != "-ub=1024 -b 4096" {
|
||||
t.Errorf("forme -ub= : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Un drapeau interdit (cache KV, flash-attn, contexte…) ne fait jamais partie de
|
||||
// la liste blanche, et une clé intouchable n'est jamais réglable.
|
||||
func TestTuneWhitelistDisjoint(t *testing.T) {
|
||||
for _, f := range tuneForbiddenFlags {
|
||||
if tuneFlagFor(f, true) != nil {
|
||||
t.Errorf("%s est à la fois interdit et réglable", f)
|
||||
}
|
||||
}
|
||||
for _, k := range tuneNeverKeys {
|
||||
if tuneKeys[k] || tuneOptInKeys[k] {
|
||||
t.Errorf("%s est à la fois intouchable et réglable", k)
|
||||
}
|
||||
}
|
||||
base := map[string]string{"CTX": "32768", "EXTRA_ARGS": "-ub 512 --cache-type-k q8_0"}
|
||||
for _, c := range []map[string]string{
|
||||
{"CTX": "65536", "EXTRA_ARGS": "-ub 512 --cache-type-k q8_0"},
|
||||
{"CTX": "32768", "EXTRA_ARGS": "-ub 512 --cache-type-k f16"},
|
||||
{"CTX": "32768", "EXTRA_ARGS": "-ub 512"},
|
||||
{"CTX": "32768", "EXTRA_ARGS": "-ub 512 --cache-type-k q8_0", "KV_TYPE": "q4_0"},
|
||||
{"CTX": "32768", "EXTRA_ARGS": "-ub 512 --cache-type-k q8_0", "SPEC": "auto"}, // opt-in non coché
|
||||
} {
|
||||
if tuneCfgCheck(base, c, false) == nil {
|
||||
t.Errorf("changement interdit accepté : %v", c)
|
||||
}
|
||||
}
|
||||
if err := tuneCfgCheck(base, map[string]string{"CTX": "32768", "EXTRA_ARGS": "-ub 512 --cache-type-k q8_0", "SPEC": "auto"}, true); err != nil {
|
||||
t.Errorf("SPEC refusé avec l'opt-in : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func tuneRichCfg() map[string]string {
|
||||
return map[string]string{
|
||||
"MODEL": "big.gguf", "CTX": "65536", "KV_TYPE": "q8_0", "MMPROJ": "mmproj.gguf", "REASONING": "on",
|
||||
"REASONING_BUDGET": "-1", "TEMP": "0.6", "TOP_K": "20", "PARALLEL": "1", "SPEC": "mtp",
|
||||
"EXTRA_ARGS": `-ot "blk\.([0-9]+)\.ffn_.*_exps\.=CPU" --n-cpu-moe 40 --cache-type-k q8_0 --flash-attn on -ub 512 -t 8 -ts 0.6,0.4`,
|
||||
}
|
||||
}
|
||||
|
||||
func tuneRichEnv() tuneEnv {
|
||||
return tuneEnv{Help: tuneTestHelp, GPUs: 2, VRAMMiB: 28 << 10, ModelBytes: 80 << 30, MoE: true, PhysCores: 8,
|
||||
SMI: true, Placement: true, OptIn: true}
|
||||
}
|
||||
|
||||
// La grille : chaque essai ne change que des clés et des drapeaux permis, ne
|
||||
// touche à aucune clé intouchable, et garde les drapeaux interdits à l'octet.
|
||||
func TestTuneGridRespectsNeverTouch(t *testing.T) {
|
||||
base := tuneRichCfg()
|
||||
env := tuneRichEnv()
|
||||
total := 0
|
||||
for _, st := range tuneStages {
|
||||
cands, why := st.Gen(base, env)
|
||||
if why != "" {
|
||||
continue
|
||||
}
|
||||
for _, c := range cands {
|
||||
total++
|
||||
if err := tuneCfgCheck(base, c.Cfg, env.OptIn); err != nil {
|
||||
t.Errorf("%s / %s : %v", st.Name, c.Label, err)
|
||||
}
|
||||
for _, k := range tuneNeverKeys {
|
||||
if c.Cfg[k] != base[k] {
|
||||
t.Errorf("%s / %s : %s modifié (%q → %q)", st.Name, c.Label, k, base[k], c.Cfg[k])
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"--cache-type-k q8_0", "--flash-attn on"} {
|
||||
if !strings.Contains(c.Cfg["EXTRA_ARGS"], k) {
|
||||
t.Errorf("%s / %s : %q perdu", st.Name, c.Label, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
t.Fatal("aucun essai généré")
|
||||
}
|
||||
}
|
||||
|
||||
// Chaque axe n'existe que si le moteur, la machine et l'utilisateur le
|
||||
// permettent.
|
||||
func TestTuneGridGating(t *testing.T) {
|
||||
base := tuneRichCfg()
|
||||
gen := func(name string, env tuneEnv) ([]tuneCand, string) {
|
||||
for _, st := range tuneStages {
|
||||
if st.Name == name {
|
||||
return st.Gen(base, env)
|
||||
}
|
||||
}
|
||||
t.Fatalf("étape %s inconnue", name)
|
||||
return nil, ""
|
||||
}
|
||||
env := tuneRichEnv()
|
||||
env.Placement = false
|
||||
if _, why := gen("placement", env); why == "" {
|
||||
t.Error("placement essayé sans « inclure le placement »")
|
||||
}
|
||||
env = tuneRichEnv()
|
||||
env.Help = strings.ReplaceAll(tuneTestHelp, "--fit", "--nofit")
|
||||
if _, why := gen("placement", env); why == "" {
|
||||
t.Error("placement essayé sur un moteur sans --fit")
|
||||
}
|
||||
env = tuneRichEnv()
|
||||
if c, why := gen("placement", env); why != "" || len(c) != 1 {
|
||||
t.Fatalf("placement attendu : %q", why)
|
||||
}
|
||||
// Marges --fit : refusées tant que le placement manuel coupe --fit, ou sans
|
||||
// --fit-target dans l'aide.
|
||||
if _, why := gen("marges", env); why == "" {
|
||||
t.Error("marges essayées alors que -ot / -ts coupent --fit")
|
||||
}
|
||||
plain := map[string]string{"MODEL": "m.gguf", "CTX": "32768"}
|
||||
for _, st := range tuneStages {
|
||||
if st.Name != "marges" {
|
||||
continue
|
||||
}
|
||||
if c, why := st.Gen(plain, env); why != "" || len(c) != 3 {
|
||||
t.Errorf("marges sur 2 cartes : %d essai(s), %q", len(c), why)
|
||||
}
|
||||
e2 := env
|
||||
e2.Help = strings.ReplaceAll(tuneTestHelp, "--fit-target", "--fit-x")
|
||||
if _, why := st.Gen(plain, e2); why == "" {
|
||||
t.Error("marges essayées sans --fit-target")
|
||||
}
|
||||
e2 = env
|
||||
e2.SMI = false
|
||||
if _, why := st.Gen(plain, e2); why == "" {
|
||||
t.Error("marges essayées sans mesure de VRAM")
|
||||
}
|
||||
}
|
||||
// Lots : sans mesure de VRAM, jamais de micro-lot plus grand.
|
||||
env = tuneRichEnv()
|
||||
env.SMI = false
|
||||
cands, _ := gen("lots", env)
|
||||
for _, c := range cands {
|
||||
if serveUBatch(c.Cfg, splitArgs(c.Cfg["EXTRA_ARGS"])) > 512 {
|
||||
t.Errorf("micro-lot monté sans mesure de VRAM : %s", c.Label)
|
||||
}
|
||||
}
|
||||
// -ub dans EXTRA_ARGS : réglé là, jamais par une clé qu'il écraserait.
|
||||
cands, _ = gen("lots", tuneRichEnv())
|
||||
for _, c := range cands {
|
||||
if c.Cfg["UBATCH"] != "" {
|
||||
t.Errorf("%s : UBATCH posé alors qu'EXTRA_ARGS fixe -ub", c.Label)
|
||||
}
|
||||
}
|
||||
// Deux cartes tout GPU : le lot reste plus grand que le micro-lot.
|
||||
dense := map[string]string{"MODEL": "m.gguf"}
|
||||
env2 := tuneEnv{Help: tuneTestHelp, GPUs: 2, VRAMMiB: 28 << 10, ModelBytes: 10 << 30, SMI: true}
|
||||
for _, st := range tuneStages {
|
||||
if st.Name != "lots" {
|
||||
continue
|
||||
}
|
||||
cs, _ := st.Gen(dense, env2)
|
||||
for _, c := range cs {
|
||||
b, _ := strconv.Atoi(knobBatch.get(c.Cfg))
|
||||
ub, _ := strconv.Atoi(knobUBatch.get(c.Cfg))
|
||||
if b <= ub {
|
||||
t.Errorf("%s : lot pas plus grand que le micro-lot sur 2 cartes", c.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"threads", "délestage"} {
|
||||
for _, st := range tuneStages {
|
||||
if st.Name == name {
|
||||
if _, why := st.Gen(dense, env2); why == "" {
|
||||
t.Errorf("%s essayé alors que tous les poids sont sur GPU", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Opt-in : rien sans la case ; avec, --backend-sampling et SPEC_N_MAX.
|
||||
env = tuneRichEnv()
|
||||
env.OptIn = false
|
||||
if _, why := gen("opt-in", env); why == "" {
|
||||
t.Error("opt-in essayé sans la case")
|
||||
}
|
||||
cands, _ = gen("opt-in", tuneRichEnv())
|
||||
labels := map[string]bool{}
|
||||
for _, c := range cands {
|
||||
labels[c.Label] = true
|
||||
}
|
||||
for _, want := range []string{"--backend-sampling", "SPEC_N_MAX=2", "SPEC_N_MAX=4", "CUDA_GRAPH_OPT=on"} {
|
||||
if !labels[want] {
|
||||
t.Errorf("opt-in : %s manquant (%v)", want, labels)
|
||||
}
|
||||
}
|
||||
// Threads : « omis » = aucun -t, jamais THREADS=0.
|
||||
cands, _ = gen("threads", tuneRichEnv())
|
||||
found := false
|
||||
for _, c := range cands {
|
||||
if c.Label == "threads omis (cœurs physiques)" {
|
||||
found = true
|
||||
if hasAnyFlag(splitArgs(c.Cfg["EXTRA_ARGS"]), "-t") || c.Cfg["THREADS"] != "" {
|
||||
t.Errorf("threads omis mais -t ou THREADS encore là : %v", c.Cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("candidat « threads omis » absent")
|
||||
}
|
||||
}
|
||||
|
||||
// La comparaison des lignes de commande : un réglage permis passe, un drapeau
|
||||
// de fidélité ou une variable non réglée changés sont « dénature ».
|
||||
func TestTuneDenature(t *testing.T) {
|
||||
base := tuneLaunch{Args: []string{"/bin/llama-server", "-m", "/m.gguf", "-c", "65536", "-b", "2048", "-ub", "512",
|
||||
"--host", "127.0.0.1", "--port", "41000", "--parallel", "1", "-ctk", "q8_0", "-ot", "exps=CPU,per_layer_token_embd=CPU",
|
||||
"--cache-ram", "8192", "--flash-attn", "on"},
|
||||
Env: map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "CUDA_SCALE_LAUNCH_QUEUES": "4x"}}
|
||||
ok := base
|
||||
ok.Args = []string{"/bin/llama-server", "-m", "/m.gguf", "-c", "65536", "-b", "4096", "-ub", "2048",
|
||||
"--host", "127.0.0.1", "--port", "41001", "--parallel", "1", "-ctk", "q8_0", "-ot", "per_layer_token_embd=CPU",
|
||||
"--cache-ram", "12000", "--flash-attn", "on", "-t", "7"}
|
||||
ok.Env = map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "GGML_OP_OFFLOAD_MIN_BATCH": "128"}
|
||||
if why := tuneDenature(base, ok, false); why != "" {
|
||||
t.Errorf("réglages permis jugés dénaturants : %s", why)
|
||||
}
|
||||
for name, mut := range map[string]func(l *tuneLaunch){
|
||||
"ctx": func(l *tuneLaunch) { l.Args[4] = "32768" },
|
||||
"kv": func(l *tuneLaunch) { l.Args[16] = "f16" },
|
||||
"ot": func(l *tuneLaunch) { l.Args[18] = "exps=CPU" },
|
||||
"fa": func(l *tuneLaunch) { l.Args[len(l.Args)-3] = "off" },
|
||||
"slots": func(l *tuneLaunch) { l.Args[14] = "2" },
|
||||
"cuda": func(l *tuneLaunch) { l.Env["CUDA_VISIBLE_DEVICES"] = "0,1" },
|
||||
"graphes": func(l *tuneLaunch) { l.Env["GGML_CUDA_GRAPH_OPT"] = "1" },
|
||||
"spec": func(l *tuneLaunch) { l.Args = append(l.Args, "--spec-draft-n-max", "4") },
|
||||
} {
|
||||
c := ok
|
||||
c.Args = append([]string(nil), ok.Args...)
|
||||
c.Env = map[string]string{}
|
||||
for k, v := range ok.Env {
|
||||
c.Env[k] = v
|
||||
}
|
||||
mut(&c)
|
||||
if tuneDenature(base, c, false) == "" {
|
||||
t.Errorf("%s : changement non vu", name)
|
||||
}
|
||||
}
|
||||
// Opt-in coché : la spéculation et les graphes CUDA deviennent réglables.
|
||||
c := ok
|
||||
c.Args = append(append([]string(nil), ok.Args...), "--spec-draft-n-max", "4")
|
||||
c.Env = map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "GGML_CUDA_GRAPH_OPT": "1"}
|
||||
if why := tuneDenature(base, c, true); why != "" {
|
||||
t.Errorf("opt-in : %s", why)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTuneLogFacts(t *testing.T) {
|
||||
log := "load_tensors: offloaded 40/49 layers to GPU\n" +
|
||||
"llama_kv_cache: size = 1024.00 MiB ( 8192 cells, 32 layers, 1/1 seqs), K (q8_0): 512.00 MiB, V (q8_0): 512.00 MiB\n" +
|
||||
"load_tensors: offloaded 49/49 layers to GPU\n"
|
||||
off, total, kv := tuneLogFacts(log)
|
||||
if off != 49 || total != 49 || kv != "q8_0/q8_0" {
|
||||
t.Errorf("faits du journal : %d/%d %q", off, total, kv)
|
||||
}
|
||||
if off, _, kv := tuneLogFacts("rien"); off != -1 || kv != "" {
|
||||
t.Errorf("journal vide : %d %q", off, kv)
|
||||
}
|
||||
if !strings.Contains(tuneLoadFailure("ggml_backend_cuda_buffer_type_alloc_buffer: cudaMalloc failed: out of memory"), "OOM") {
|
||||
t.Error("OOM non reconnu")
|
||||
}
|
||||
}
|
||||
|
||||
// Le score et le départage : un gain ne compte qu'au-delà de 3 % ET de
|
||||
// l'écart entre passages, et sur deux passages.
|
||||
func TestTuneScoreAndTieBreak(t *testing.T) {
|
||||
w := tuneWeights{K: 2000, G: 600, FCold: 0.05}
|
||||
res := &benchResult{PromptPerSecond: 1000, PredictedPerSec: 50, Depth: &benchDepth{Target: 32768,
|
||||
Cold: &benchTurn{PromptPerSecond: 800},
|
||||
DecodePerSec: 40, CachedPerSec: 999,
|
||||
Turns: []benchTurn{{New: 2100, Cached: 30000, Expected: 30048, PromptMs: 2000},
|
||||
{New: 2100, Cached: 32300, Expected: 32348, PromptMs: 2000}}}}
|
||||
r, err := tuneRunFrom(res, w)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Jetons neufs réels : (2100+30000-30048)+(2100+32300-32348) = 4104 en 4 s.
|
||||
if r.CachedPP < 1025 || r.CachedPP > 1027 {
|
||||
t.Errorf("prefill des tours : %.1f", r.CachedPP)
|
||||
}
|
||||
want := 2000/r.CachedPP + 600/40.0 + 0.05*32768/800
|
||||
if d := r.TurnSec - want; d > 1e-9 || d < -1e-9 {
|
||||
t.Errorf("durée d'un tour : %.3f au lieu de %.3f", r.TurnSec, want)
|
||||
}
|
||||
res.Depth.Partial = "contexte plein"
|
||||
if _, err := tuneRunFrom(res, w); err == nil {
|
||||
t.Error("mesure partielle acceptée")
|
||||
}
|
||||
base := []float64{20, 20.4}
|
||||
if _, _, ok := tuneWins(base, []float64{19.7, 19.8}); ok {
|
||||
t.Error("gain de ~2 % accepté (sous le seuil)")
|
||||
}
|
||||
if _, _, ok := tuneWins(base, []float64{18}); ok {
|
||||
t.Error("un seul passage suffit à gagner")
|
||||
}
|
||||
if g, _, ok := tuneWins(base, []float64{18, 18.1}); !ok || g < 0.09 {
|
||||
t.Errorf("gain net refusé (%.3f)", g)
|
||||
}
|
||||
// Écart entre passages plus grand que le gain : refusé.
|
||||
if _, need, ok := tuneWins([]float64{18, 22}, []float64{18.5, 18.6}); ok || need < 0.19 {
|
||||
t.Errorf("bruit non pris en compte (seuil %.2f)", need)
|
||||
}
|
||||
if !tuneWorthRepeat(base, 19) || tuneWorthRepeat(base, 19.8) {
|
||||
t.Error("confirmation mal décidée")
|
||||
}
|
||||
}
|
||||
|
||||
// La réécriture du preset ne change que les clés réglées ; commentaires, NAME
|
||||
// et autres clés restent, EXTRA_ARGS garde ses jetons interdits.
|
||||
func TestTunePatchPreset(t *testing.T) {
|
||||
content := "# NAME=Gros MoE\n# commentaire\nMODEL=big.gguf\nCTX=65536\nUBATCH=512\n" +
|
||||
`EXTRA_ARGS="--cache-type-k q8_0 --flash-attn on -t 8"` + "\n"
|
||||
base := parseEnv(content)
|
||||
win := knobThreads.set(base, "")
|
||||
win = setKey(win, "UBATCH", "2048")
|
||||
win = setKey(win, "BATCH", "4096")
|
||||
out, err := tunePatchPreset(content, base, win)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range []string{"# NAME=Gros MoE", "# commentaire", "MODEL=big.gguf", "CTX=65536", "UBATCH=2048", "BATCH=4096",
|
||||
"--cache-type-k q8_0 --flash-attn on"} {
|
||||
if !strings.Contains(out, s) {
|
||||
t.Errorf("%q absent de :\n%s", s, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "-t 8") {
|
||||
t.Errorf("-t non retiré :\n%s", out)
|
||||
}
|
||||
bad := setKey(base, "CTX", "32768")
|
||||
if _, err := tunePatchPreset(content, base, bad); err == nil {
|
||||
t.Error("clé intouchable réécrite")
|
||||
}
|
||||
}
|
||||
|
||||
// Le verrou : exclusif entre processus, refusé tant que son propriétaire vit,
|
||||
// écarté quand il est mort ; et serviceAction refuse tout démarrage pendant ce
|
||||
// temps, sur toutes les plateformes, avant de toucher au système.
|
||||
func TestTuneLock(t *testing.T) {
|
||||
testHome(t)
|
||||
alive := true
|
||||
prev := tuneProcAlive
|
||||
tuneProcAlive = func(p tuneProc) bool { return alive && p.PID == os.Getpid() }
|
||||
t.Cleanup(func() { tuneProcAlive = prev })
|
||||
|
||||
if err := tuneGuard(); err != nil {
|
||||
t.Fatalf("garde levée sans verrou : %v", err)
|
||||
}
|
||||
l, err := tuneLockAcquire("cli", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !errors.Is(tuneGuard(), errTuneBusy) {
|
||||
t.Error("garde muette pendant l'optimisation")
|
||||
}
|
||||
if _, err := tuneLockAcquire("web", false); err == nil {
|
||||
t.Error("second verrou accordé")
|
||||
}
|
||||
for _, a := range []string{"start", "restart"} {
|
||||
if err := serviceAction(a); !errors.Is(err, errTuneBusy) {
|
||||
t.Errorf("serviceAction(%s) pendant l'optimisation : %v", a, err)
|
||||
}
|
||||
}
|
||||
if err := SwitchToPreset(filepath.Join(t.TempDir(), "x.env")); !errors.Is(err, errTuneBusy) {
|
||||
t.Errorf("bascule de preset pendant l'optimisation : %v", err)
|
||||
}
|
||||
l.setTrial(&tuneProc{PID: 999999, Start: "x"})
|
||||
if o, ok := readTuneOwner(tuneLockPath()); !ok || o.Trial == nil || o.Trial.PID != 999999 || !o.MainWasActive {
|
||||
t.Errorf("essai non noté dans le verrou : %+v", o)
|
||||
}
|
||||
l.release()
|
||||
if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) {
|
||||
t.Error("verrou non rendu")
|
||||
}
|
||||
|
||||
// Propriétaire mort : le verrou est périmé, il ne bloque rien et cède la
|
||||
// place. L'essai noté n'est pas tué (identité non concordante).
|
||||
l, err = tuneLockAcquire("cli", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.setTrial(&tuneProc{PID: 999999, Start: "x"})
|
||||
alive = false
|
||||
if err := tuneGuard(); err != nil {
|
||||
t.Errorf("verrou périmé encore bloquant : %v", err)
|
||||
}
|
||||
if !tuneReapStale() {
|
||||
t.Error("MainWasActive perdu au nettoyage")
|
||||
}
|
||||
if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) {
|
||||
t.Error("verrou périmé non retiré")
|
||||
}
|
||||
alive = true
|
||||
l2, err := tuneLockAcquire("web", false)
|
||||
if err != nil {
|
||||
t.Fatalf("verrou refusé après nettoyage : %v", err)
|
||||
}
|
||||
l2.release()
|
||||
// Un verrou d'un autre propriétaire n'est jamais retiré par release.
|
||||
l3, _ := tuneLockAcquire("cli", false)
|
||||
other := &tuneLock{path: tuneLockPath(), owner: tuneOwner{Owner: tuneProc{PID: os.Getpid(), Start: "autre"}}}
|
||||
other.release()
|
||||
if _, err := os.Stat(tuneLockPath()); err != nil {
|
||||
t.Error("verrou d'autrui retiré")
|
||||
}
|
||||
l3.release()
|
||||
}
|
||||
|
||||
// Le moteur d'essai ne lit ni n'écrit config.env : « loki serve » à blanc, avec
|
||||
// la configuration de l'essai, compose sa ligne de commande (adresse privée,
|
||||
// surcharges) et laisse la configuration active intacte.
|
||||
func TestTuneTrialNeverWritesConfig(t *testing.T) {
|
||||
home := testHome(t)
|
||||
for _, k := range []string{"PATH", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "CUDA_VISIBLE_DEVICES", "CUDA_DEVICE_ORDER",
|
||||
"CUDA_SCALE_LAUNCH_QUEUES", "GGML_OP_OFFLOAD_MIN_BATCH", "GGML_CUDA_GRAPH_OPT", tuneTrialEnv, tuneDryRunEnv} {
|
||||
t.Setenv(k, os.Getenv(k))
|
||||
}
|
||||
bin := filepath.Join(home, "llama-server-factice")
|
||||
model := filepath.Join(home, "m.gguf")
|
||||
for _, p := range []string{bin, model} {
|
||||
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
active := map[string]string{"BIN": bin, "MODEL": model, "CTX": "8192", "UBATCH": "512", "PORT": "8080",
|
||||
"EXTRA_ARGS": "--cache-type-k q8_0 --port 9999"}
|
||||
if err := WriteConfig(active); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := &tuneTrialer{dir: filepath.Join(home, "tune", "run")}
|
||||
trial := knobUBatch.set(active, "2048")
|
||||
dir, port, err := tr.prepare(trial)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(tuneTrialEnv, dir)
|
||||
t.Setenv(tuneDryRunEnv, "1")
|
||||
if err := cmdServe(nil); err != nil {
|
||||
t.Fatalf("loki serve à blanc : %v", err)
|
||||
}
|
||||
if got := ReadConfig(); !reflect.DeepEqual(got, active) {
|
||||
t.Errorf("configuration active modifiée :\n%v\nau lieu de\n%v", got, active)
|
||||
}
|
||||
l, err := readTuneLaunch(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if argValue(l.Args, "-ub") != "2048" || argValue(l.Args, "--host") != "127.0.0.1" ||
|
||||
argValue(l.Args, "--port") != strconv.Itoa(port) || argValue(l.Args, "--cache-type-k") != "q8_0" {
|
||||
t.Errorf("ligne de l'essai inattendue : %q", l.Args)
|
||||
}
|
||||
for i, a := range l.Args {
|
||||
if a == "--port" && l.Args[i+1] == "9999" {
|
||||
t.Errorf("--port d'EXTRA_ARGS non forcé : %q", l.Args)
|
||||
}
|
||||
}
|
||||
if l.Probe == nil {
|
||||
t.Error("sonde absente de la ligne de l'essai")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "slots")); err == nil {
|
||||
t.Error("l'essai a touché au dossier des slots du vrai moteur")
|
||||
}
|
||||
}
|
||||
|
||||
// Après application, la configuration doit être exactement la référence plus
|
||||
// les clés réglées.
|
||||
func TestTuneAppliedMismatch(t *testing.T) {
|
||||
base := map[string]string{"MODEL": "m.gguf", "UBATCH": "512", "EXTRA_ARGS": "-t 8"}
|
||||
res := &tuneResult{BaseFP: configFingerprint(base), Set: map[string]string{"UBATCH": "2048", "EXTRA_ARGS": ""},
|
||||
Old: map[string]string{"UBATCH": "512", "EXTRA_ARGS": "-t 8"}}
|
||||
if why := tuneAppliedMismatch(map[string]string{"MODEL": "m.gguf", "UBATCH": "2048"}, res); why != "" {
|
||||
t.Errorf("application conforme refusée : %s", why)
|
||||
}
|
||||
if why := tuneAppliedMismatch(map[string]string{"MODEL": "m.gguf", "UBATCH": "1024"}, res); why == "" {
|
||||
t.Error("valeur réglée différente non vue")
|
||||
}
|
||||
if why := tuneAppliedMismatch(map[string]string{"MODEL": "autre.gguf", "UBATCH": "2048"}, res); why == "" {
|
||||
t.Error("changement hors réglages non vu")
|
||||
}
|
||||
}
|
||||
|
||||
// Application : la copie ne touche pas au preset d'origine ; le preset lui-même
|
||||
// est réécrit, le moteur redémarré, et une sonde qui échoue (OOM au premier
|
||||
// long prompt, faux moteur) ramène l'ancienne version — preset ET configuration.
|
||||
func TestTuneApplyAndRevert(t *testing.T) {
|
||||
testHome(t)
|
||||
content := "# NAME=Dense\nMODEL=m.gguf\nUBATCH=512\n"
|
||||
if err := os.MkdirAll(presetsDir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(presetsDir(), "Dense.env"), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteConfig(parseEnv(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := &tuneResult{PresetID: "Dense", PresetName: "Dense", PresetFP: presetFingerprint([]byte(content)),
|
||||
BaseFP: configFingerprint(parseEnv(content)), Set: map[string]string{"UBATCH": "2048"},
|
||||
Old: map[string]string{"UBATCH": "512"}, Best: "UBATCH=2048"}
|
||||
var calls []string
|
||||
prevSvc, prevProbe := tuneSvc, tuneProbeFn
|
||||
t.Cleanup(func() { tuneSvc, tuneProbeFn = prevSvc, prevProbe })
|
||||
tuneSvc = func(a string) error { calls = append(calls, a); return nil }
|
||||
|
||||
if _, err := tuneApply(context.Background(), res, tuneApplyCopy, func(string) {}); err != nil {
|
||||
t.Fatalf("copie : %v", err)
|
||||
}
|
||||
if got, _ := ReadPreset("Dense"); got != content || len(calls) != 0 {
|
||||
t.Fatalf("la copie a touché au preset ou au moteur : %q %v", got, calls)
|
||||
}
|
||||
copyBody, err := ReadPreset("Dense (optimisé)")
|
||||
if err != nil || !strings.Contains(copyBody, "UBATCH=2048") {
|
||||
t.Fatalf("copie absente ou fausse : %q %v", copyBody, err)
|
||||
}
|
||||
|
||||
tuneProbeFn = func(context.Context, *tuneResult, func(string)) error { return errors.New("sonde : out of memory") }
|
||||
_, err = tuneApply(context.Background(), res, tuneApplyPreset, func(string) {})
|
||||
if err == nil || !strings.Contains(err.Error(), "rétablie") {
|
||||
t.Fatalf("échec de la sonde sans retour arrière : %v", err)
|
||||
}
|
||||
if got, _ := ReadPreset("Dense"); got != content {
|
||||
t.Errorf("preset non rétabli : %q", got)
|
||||
}
|
||||
if ReadConfig()["UBATCH"] != "512" {
|
||||
t.Errorf("configuration non rétablie : %v", ReadConfig())
|
||||
}
|
||||
if !reflect.DeepEqual(calls, []string{"restart", "restart"}) {
|
||||
t.Errorf("redémarrages : %v", calls)
|
||||
}
|
||||
if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) {
|
||||
t.Error("verrou non rendu après l'application")
|
||||
}
|
||||
|
||||
calls = nil
|
||||
tuneProbeFn = func(context.Context, *tuneResult, func(string)) error { return nil }
|
||||
if _, err := tuneApply(context.Background(), res, tuneApplyPreset, func(string) {}); err != nil {
|
||||
t.Fatalf("application : %v", err)
|
||||
}
|
||||
if got, _ := ReadPreset("Dense"); !strings.Contains(got, "UBATCH=2048") || !strings.Contains(got, "# NAME=Dense") {
|
||||
t.Errorf("preset non réglé : %q", got)
|
||||
}
|
||||
if ReadConfig()["UBATCH"] != "2048" || len(calls) != 1 {
|
||||
t.Errorf("configuration %v, redémarrages %v", ReadConfig(), calls)
|
||||
}
|
||||
// Le preset a changé depuis la mesure : plus rien ne s'applique.
|
||||
if _, err := tuneApply(context.Background(), res, tuneApplyPreset, func(string) {}); err == nil {
|
||||
t.Error("résultat appliqué sur un preset modifié depuis la mesure")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTuneHelperSleep n'est pas un test : relancé par TestTuneReapOrphan comme
|
||||
// faux essai orphelin, il dort jusqu'à ce qu'on le tue.
|
||||
func TestTuneHelperSleep(t *testing.T) {
|
||||
if os.Getenv("LOKI_TUNE_TEST_SLEEP") != "1" {
|
||||
t.Skip("processus auxiliaire")
|
||||
}
|
||||
time.Sleep(60 * time.Second)
|
||||
}
|
||||
|
||||
// Un essai orphelin (propriétaire du verrou mort) est arrêté avant tout
|
||||
// démarrage du moteur — seulement si son identité concorde.
|
||||
func TestTuneReapOrphan(t *testing.T) {
|
||||
testHome(t)
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command(self, "-test.run=^TestTuneHelperSleep$")
|
||||
cmd.Env = append(os.Environ(), "LOKI_TUNE_TEST_SLEEP=1")
|
||||
tuneTrialAttr(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { _ = cmd.Wait(); close(done) }()
|
||||
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
||||
start, exe, ok := procIdentity(cmd.Process.Pid)
|
||||
if !ok {
|
||||
t.Fatal("identité du processus auxiliaire illisible")
|
||||
}
|
||||
// Propriétaire mort : un PID qui ne tourne plus (le test lui-même, avec un
|
||||
// instant de démarrage qui n'est pas le sien).
|
||||
dead := tuneProc{PID: os.Getpid(), Start: "pas-moi"}
|
||||
write := func(trial tuneProc) {
|
||||
b, _ := json.Marshal(tuneOwner{Owner: dead, Via: "cli", Trial: &trial})
|
||||
if err := os.WriteFile(tuneLockPath(), b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Identité qui ne concorde pas (PID recyclé) : on ne tue rien.
|
||||
write(tuneProc{PID: cmd.Process.Pid, Start: start + "x", Exes: []string{exe}})
|
||||
tuneReapStale()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("processus tué sur la foi du seul PID")
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) {
|
||||
t.Fatal("verrou périmé non retiré")
|
||||
}
|
||||
// Identité complète : arrêté.
|
||||
write(tuneProc{PID: cmd.Process.Pid, Start: start, Exes: []string{exe}})
|
||||
if err := tuneGuard(); err != nil {
|
||||
t.Fatalf("verrou périmé bloquant : %v", err)
|
||||
}
|
||||
tuneReapStale()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("essai orphelin toujours en vie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTuneStageWanted(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
only []string
|
||||
want bool
|
||||
}{
|
||||
{"lots", nil, true},
|
||||
{"délestage", []string{"delestage"}, true},
|
||||
{"files CUDA", []string{"files"}, true},
|
||||
{"threads", []string{"lots"}, false},
|
||||
{"placement", []string{"aucune"}, true}, // sa propre case décide
|
||||
{"opt-in", []string{"lots"}, true},
|
||||
{"marges", []string{"aucune"}, false},
|
||||
} {
|
||||
if got := tuneStageWanted(c.name, c.only); got != c.want {
|
||||
t.Errorf("%s / %v : %v", c.name, c.only, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Corrections de relecture du lot 2 : un verrou qui porte NOTRE PID sans que
|
||||
// nous en tenions un vient d'un processus mort (PID réattribué après un
|
||||
// redémarrage, même instant de démarrage relatif au boot) : il ne bloque rien.
|
||||
// Un verrou illisible tout juste créé est en cours d'écriture : ni retiré ni
|
||||
// bloquant ; vieux, il est écarté.
|
||||
func TestTuneLockStaleSelfAndFresh(t *testing.T) {
|
||||
testHome(t)
|
||||
prev := tuneProcAlive
|
||||
tuneProcAlive = func(tuneProc) bool { return true } // l'identité concorde
|
||||
t.Cleanup(func() { tuneProcAlive = prev })
|
||||
if err := os.MkdirAll(filepath.Dir(tuneLockPath()), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := json.Marshal(tuneOwner{Owner: selfProc(), Via: "web", Phase: "essais"})
|
||||
if err := os.WriteFile(tuneLockPath(), b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tuneGuard(); err != nil {
|
||||
t.Fatalf("verrou d'un ancien processus au même PID encore bloquant : %v", err)
|
||||
}
|
||||
l, err := tuneLockAcquire("cli", false)
|
||||
if err != nil {
|
||||
t.Fatalf("verrou périmé non écarté : %v", err)
|
||||
}
|
||||
if !errors.Is(tuneGuard(), errTuneBusy) {
|
||||
t.Error("notre propre verrou ne bloque plus")
|
||||
}
|
||||
l.release()
|
||||
if _, held := tuneHeld.Load(tuneHeldKey(l.owner)); held {
|
||||
t.Error("verrou rendu encore inscrit comme tenu")
|
||||
}
|
||||
|
||||
// Illisible et neuf : laissé en place.
|
||||
if err := os.WriteFile(tuneLockPath(), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tuneReapStale()
|
||||
if _, err := os.Stat(tuneLockPath()); err != nil {
|
||||
t.Fatal("verrou en cours d'écriture retiré")
|
||||
}
|
||||
// Illisible et vieux : écarté.
|
||||
old := time.Now().Add(-2 * tuneLockFreshness)
|
||||
if err := os.Chtimes(tuneLockPath(), old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tuneReapStale()
|
||||
if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) {
|
||||
t.Fatal("verrou illisible abandonné non retiré")
|
||||
}
|
||||
}
|
||||
|
||||
// Les binaires remplacés par une mise à jour (« (deleted) » sous Linux) restent
|
||||
// le même processus.
|
||||
func TestTuneSameExeDeleted(t *testing.T) {
|
||||
if !sameExe("/usr/local/bin/loki", "/usr/local/bin/loki (deleted)") {
|
||||
t.Error("binaire remplacé pris pour un autre")
|
||||
}
|
||||
if sameExe("/usr/local/bin/loki", "/usr/bin/llama-server") {
|
||||
t.Error("binaires différents confondus")
|
||||
}
|
||||
}
|
||||
|
||||
// Une application interrompue avant sa vérification (Loki tué pendant la
|
||||
// sonde) est défaite quand le verrou périmé est écarté : preset ET
|
||||
// configuration reviennent à la version sauvegardée.
|
||||
func TestTuneUndoInterruptedApply(t *testing.T) {
|
||||
testHome(t)
|
||||
prev := tuneProcAlive
|
||||
tuneProcAlive = func(tuneProc) bool { return false } // propriétaire mort
|
||||
t.Cleanup(func() { tuneProcAlive = prev })
|
||||
old := "# NAME=Dense\nMODEL=m.gguf\nUBATCH=512\n"
|
||||
patched := "# NAME=Dense\nMODEL=m.gguf\nUBATCH=2048\n"
|
||||
if err := os.MkdirAll(presetsDir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(presetsDir(), "Dense.env"), []byte(patched), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteConfig(parseEnv(patched)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backup := filepath.Join(t.TempDir(), "Dense.env")
|
||||
if err := os.WriteFile(backup, []byte(old), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(tuneLockPath()), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := json.Marshal(tuneOwner{Owner: tuneProc{PID: 999999, Start: "x"}, Via: "apply", Phase: "application",
|
||||
Backup: backup, PresetID: "Dense", PresetName: "Dense", MainWasActive: true})
|
||||
if err := os.WriteFile(tuneLockPath(), b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tuneReapStale() {
|
||||
t.Error("MainWasActive perdu")
|
||||
}
|
||||
if got, _ := ReadPreset("Dense"); got != old {
|
||||
t.Errorf("preset non rétabli : %q", got)
|
||||
}
|
||||
if ReadConfig()["UBATCH"] != "512" {
|
||||
t.Errorf("configuration non rétablie : %v", ReadConfig())
|
||||
}
|
||||
// Hors phase d'application, rien n'est touché.
|
||||
if err := WriteConfig(parseEnv(patched)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ = json.Marshal(tuneOwner{Owner: tuneProc{PID: 999999, Start: "x"}, Via: "cli", Phase: "essais",
|
||||
Backup: backup, PresetID: "Dense", PresetName: "Dense"})
|
||||
if err := os.WriteFile(tuneLockPath(), b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tuneReapStale()
|
||||
if ReadConfig()["UBATCH"] != "2048" {
|
||||
t.Error("configuration modifiée hors phase d'application")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package loki
|
||||
|
||||
// backup_bundle.go — construit et restaure un paquet de sauvegarde
|
||||
// {mémoire, presets, réglages}, CHIFFRÉ côté agent avant de quitter la machine.
|
||||
//
|
||||
// Le relais ne voit qu'un blob opaque (voir jean-relay/backup.go). Comme le tar
|
||||
// entier est chiffré, même les noms de fichiers et de presets sont cachés.
|
||||
//
|
||||
// Format du blob :
|
||||
// magic "LOKIBK1" || uint32(len entête) || entête JSON || corps
|
||||
// entête : { v, salt, kdf, box_pw, box_rec } — la DEK de sauvegarde wrappée
|
||||
// par la KEK(mot de passe) ET par la KEK(clé de récupération).
|
||||
// corps : gcmSeal(backupDEK, tar.gz du bundle).
|
||||
//
|
||||
// Restauration : mot de passe → KEK → déballe la DEK → déchiffre le corps. Ne
|
||||
// dépend d'AUCUN état de l'ancienne machine : un serveur vierge suffit.
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var backupMagic = []byte("LKBK1") // v2 : entête = keyvault, corps chiffré par la DEK
|
||||
|
||||
const backupBodyAAD = "loki-backup-body"
|
||||
|
||||
// buildBundleTar empaquète mémoire + presets + réglages en un tar.gz.
|
||||
// Les fichiers mémoire sont pris TELS QUELS (chiffrés si le chiffrement est
|
||||
// actif) : le bundle reste donc chiffré même avant l'enveloppe de sauvegarde.
|
||||
func buildBundleTar() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
|
||||
addDir := func(prefix, dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil // dossier absent = rien à ajouter
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hdr := &tar.Header{Name: prefix + "/" + e.Name(), Mode: 0o600, Size: int64(len(b))}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mémoire de TOUS les projets : un sous-dossier par projet (memory/<slug>/...).
|
||||
if roots, err := os.ReadDir(projectsRoot()); err == nil {
|
||||
for _, d := range roots {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
if err := addDir("memory/"+d.Name(), filepath.Join(projectsRoot(), d.Name())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := addDir("presets", presetsDir()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Réglages (bucket config) sérialisés en JSON.
|
||||
cfg, _ := json.Marshal(ReadConfig())
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "config.json", Mode: 0o600, Size: int64(len(cfg))}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tw.Write(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// buildBackupBlob assemble le paquet chiffré : magic || uint32(len keyvault) ||
|
||||
// keyvault JSON || gcmSeal(DEK, tar). AUCUN mot de passe de sauvegarde : le corps
|
||||
// est chiffré avec la DEK de la mémoire (déjà en RAM), et le keyvault (la DEK
|
||||
// wrappée par la clé d'API + la clé de récupération) voyage en entête. La
|
||||
// restauration n'a donc besoin que de la clé d'API. Le relais ne voit qu'un blob
|
||||
// opaque : le keyvault en entête ne contient que la DEK CHIFFRÉE.
|
||||
func buildBackupBlob(v *keyVault, dek, tarData []byte) ([]byte, error) {
|
||||
kv, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := gcmSeal(dek, tarData, []byte(backupBodyAAD))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out bytes.Buffer
|
||||
out.Write(backupMagic)
|
||||
var lenbuf [4]byte
|
||||
binary.BigEndian.PutUint32(lenbuf[:], uint32(len(kv)))
|
||||
out.Write(lenbuf[:])
|
||||
out.Write(kv)
|
||||
out.Write(body)
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// openBackupBlob lit l'entête keyvault, l'ouvre avec secret (clé d'API OU clé de
|
||||
// récupération) pour retrouver la DEK, puis déchiffre le corps.
|
||||
func openBackupBlob(blob []byte, secret string) ([]byte, error) {
|
||||
if !bytes.HasPrefix(blob, backupMagic) {
|
||||
return nil, fmt.Errorf("format de sauvegarde non reconnu")
|
||||
}
|
||||
rest := blob[len(backupMagic):]
|
||||
if len(rest) < 4 {
|
||||
return nil, fmt.Errorf("blob tronqué")
|
||||
}
|
||||
n := binary.BigEndian.Uint32(rest[:4])
|
||||
rest = rest[4:]
|
||||
if int(n) > len(rest) {
|
||||
return nil, fmt.Errorf("entête tronqué")
|
||||
}
|
||||
var v keyVault
|
||||
if err := json.Unmarshal(rest[:n], &v); err != nil {
|
||||
return nil, fmt.Errorf("entête illisible : %w", err)
|
||||
}
|
||||
body := rest[n:]
|
||||
dek, _, err := v.unlockWith(secret)
|
||||
if err != nil {
|
||||
dek, _, err = v.unlockWith(normalizeRecovery(secret))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clé incorrecte")
|
||||
}
|
||||
return gcmOpen(dek, body, []byte(backupBodyAAD))
|
||||
}
|
||||
|
||||
// restoreBundleTar réécrit mémoire + presets + réglages depuis un tar.gz. Prend
|
||||
// D'ABORD un snapshot de sécurité de la mémoire, puis écrit de façon vérifiée.
|
||||
func restoreBundleTar(tarData []byte) error {
|
||||
if _, err := snapshotMemory("avant-restauration"); err != nil {
|
||||
return fmt.Errorf("snapshot de sécurité impossible, restauration annulée : %w", err)
|
||||
}
|
||||
gz, err := gzip.NewReader(bytes.NewReader(tarData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
var cfgJSON []byte
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Les chemins tar sont TOUJOURS à séparateur « / » (jamais l'OS) : on
|
||||
// raisonne en slash, sinon le préfixe "memory/" casse sous Windows.
|
||||
name := path.Clean("/" + strings.ReplaceAll(hdr.Name, "\\", "/"))[1:]
|
||||
if strings.Contains(name, "..") {
|
||||
continue // sécurité : jamais de traversée
|
||||
}
|
||||
base := path.Base(name)
|
||||
switch {
|
||||
case name == "config.json":
|
||||
cfgJSON = b
|
||||
case strings.HasPrefix(name, "memory/"):
|
||||
// Chemin relatif SOUS memory/ (memory/<slug>/page.md). Les sauvegardes
|
||||
// d'avant les projets stockaient les pages à plat (memory/page.md) : on
|
||||
// les rattache au projet par défaut.
|
||||
rel := strings.TrimPrefix(name, "memory/")
|
||||
if !strings.Contains(rel, "/") {
|
||||
rel = defaultProjectSlug + "/" + rel
|
||||
}
|
||||
dst := filepath.Join(projectsRoot(), filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := memWriteFileVerified(dst, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
case strings.HasPrefix(name, "presets/"):
|
||||
if err := os.MkdirAll(presetsDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := memWriteFileVerified(filepath.Join(presetsDir(), base), b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(cfgJSON) > 0 {
|
||||
var cfg map[string]string
|
||||
if json.Unmarshal(cfgJSON, &cfg) == nil {
|
||||
_ = WriteConfig(cfg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Un paquet exporté doit se rouvrir avec la clé de pilotage comme avec la clé de
|
||||
// récupération, et rendre la mémoire à l'identique. C'est le scénario « je
|
||||
// remonte mon conteneur ailleurs » : rien d'autre que le fichier et la clé.
|
||||
func TestBackupBundleRoundTrip(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
if err := MemAdd("notes.md", "# Notes\nsecret du client\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recovery, err := EnableMemEncryption("clé-de-pilotage")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
v, err := loadVault()
|
||||
if err != nil || v == nil {
|
||||
t.Fatalf("coffre introuvable après activation: %v", err)
|
||||
}
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tarData, err := buildBundleTar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blob, err := buildBackupBlob(v, dek, tarData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Le paquet ne doit rien laisser voir en clair.
|
||||
if strings.Contains(string(blob), "secret du client") {
|
||||
t.Fatal("le contenu d'une page apparaît en clair dans le paquet")
|
||||
}
|
||||
|
||||
// La clé de pilotage l'ouvre…
|
||||
if _, err := openBackupBlob(blob, "clé-de-pilotage"); err != nil {
|
||||
t.Fatalf("ouverture avec la clé de pilotage: %v", err)
|
||||
}
|
||||
// …la clé de récupération aussi…
|
||||
back, err := openBackupBlob(blob, recovery)
|
||||
if err != nil {
|
||||
t.Fatalf("ouverture avec la clé de récupération: %v", err)
|
||||
}
|
||||
// …et une mauvaise clé, non.
|
||||
if _, err := openBackupBlob(blob, "pas la bonne"); err == nil {
|
||||
t.Fatal("une clé fausse aurait dû être refusée")
|
||||
}
|
||||
|
||||
// Page effacée puis restaurée depuis le paquet : contenu intact.
|
||||
p, _ := safeMemPath("notes.md")
|
||||
if err := os.Remove(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restoreBundleTar(back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := memPageText("notes.md")
|
||||
if !ok || !strings.Contains(got, "secret du client") {
|
||||
t.Fatalf("page non restaurée: %q (lisible=%v)", got, ok)
|
||||
}
|
||||
// Le snapshot de sécurité pris avant restauration doit exister.
|
||||
if entries, err := os.ReadDir(filepath.Join(LokiHome(), "backups", "memory")); err != nil || len(entries) == 0 {
|
||||
t.Fatal("aucun snapshot pris avant la restauration")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package loki
|
||||
|
||||
// browser_grid.go — surimpose une GRILLE DE COORDONNÉES sur le screenshot envoyé
|
||||
// au MODÈLE (pas sur celui sauvegardé pour l'utilisateur). Un petit modèle vise
|
||||
// mal au pixel près ; avec des lignes graduées tous les 100 px et des étiquettes
|
||||
// chiffrées, il lit x/y précisément et browser_click_xy tombe juste du premier
|
||||
// coup (bandeaux cookies en iframe, canvas, jeux…). Sans dépendance externe : la
|
||||
// police de chiffres est un bitmap 3×5 codé ici.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const gridStep = 100 // espacement des lignes, en pixels
|
||||
|
||||
// digits5x3 : bitmap 5 lignes × 3 colonnes pour chaque chiffre (# = pixel plein).
|
||||
var digits5x3 = map[rune][5]string{
|
||||
'0': {"###", "# #", "# #", "# #", "###"},
|
||||
'1': {" # ", "## ", " # ", " # ", "###"},
|
||||
'2': {"###", " #", "###", "# ", "###"},
|
||||
'3': {"###", " #", "###", " #", "###"},
|
||||
'4': {"# #", "# #", "###", " #", " #"},
|
||||
'5': {"###", "# ", "###", " #", "###"},
|
||||
'6': {"###", "# ", "###", "# #", "###"},
|
||||
'7': {"###", " #", " #", " #", " #"},
|
||||
'8': {"###", "# #", "###", "# #", "###"},
|
||||
'9': {"###", "# #", "###", " #", "###"},
|
||||
}
|
||||
|
||||
// overlayGrid décode un PNG, y dessine la grille + les graduations, et renvoie le
|
||||
// PNG résultant. En cas d'erreur (image illisible), renvoie l'entrée inchangée.
|
||||
func overlayGrid(raw []byte) []byte {
|
||||
src, err := png.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
b := src.Bounds()
|
||||
img := image.NewRGBA(b)
|
||||
draw.Draw(img, b, src, b.Min, draw.Src)
|
||||
|
||||
line := color.RGBA{255, 0, 200, 130} // magenta translucide pour les lignes
|
||||
labelBG := color.RGBA{0, 0, 0, 200} // fond sombre derrière les chiffres
|
||||
labelFG := color.RGBA{255, 255, 0, 255} // chiffres jaunes (contraste sur toute page)
|
||||
w, h := b.Dx(), b.Dy()
|
||||
|
||||
// Lignes verticales + étiquette x en haut.
|
||||
for x := gridStep; x < w; x += gridStep {
|
||||
for y := 0; y < h; y++ {
|
||||
blend(img, b.Min.X+x, b.Min.Y+y, line)
|
||||
}
|
||||
drawLabel(img, b.Min.X+x+2, b.Min.Y+2, strconv.Itoa(x), labelFG, labelBG)
|
||||
}
|
||||
// Lignes horizontales + étiquette y à gauche.
|
||||
for y := gridStep; y < h; y += gridStep {
|
||||
for x := 0; x < w; x++ {
|
||||
blend(img, b.Min.X+x, b.Min.Y+y, line)
|
||||
}
|
||||
drawLabel(img, b.Min.X+2, b.Min.Y+y+2, strconv.Itoa(y), labelFG, labelBG)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := png.Encode(&out, img); err != nil {
|
||||
return raw
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// blend applique une couleur semi-transparente (alpha) sur un pixel.
|
||||
func blend(img *image.RGBA, x, y int, c color.RGBA) {
|
||||
if !(image.Pt(x, y).In(img.Bounds())) {
|
||||
return
|
||||
}
|
||||
a := float64(c.A) / 255
|
||||
o := img.RGBAAt(x, y)
|
||||
img.SetRGBA(x, y, color.RGBA{
|
||||
R: uint8(float64(c.R)*a + float64(o.R)*(1-a)),
|
||||
G: uint8(float64(c.G)*a + float64(o.G)*(1-a)),
|
||||
B: uint8(float64(c.B)*a + float64(o.B)*(1-a)),
|
||||
A: 255,
|
||||
})
|
||||
}
|
||||
|
||||
// drawLabel écrit un nombre (police 3×5, échelle ×2) avec un fond sombre pour
|
||||
// rester lisible sur n'importe quel fond.
|
||||
func drawLabel(img *image.RGBA, x, y int, s string, fg, bg color.RGBA) {
|
||||
const sc = 2
|
||||
glyphW, glyphH, gap := 3*sc, 5*sc, sc
|
||||
boxW := len(s)*(glyphW+gap) + gap
|
||||
boxH := glyphH + 2*gap
|
||||
// Fond.
|
||||
for dy := -gap; dy < boxH-gap; dy++ {
|
||||
for dx := -gap; dx < boxW-gap; dx++ {
|
||||
setOpaque(img, x+dx, y+dy, bg)
|
||||
}
|
||||
}
|
||||
// Chiffres.
|
||||
cx := x
|
||||
for _, r := range s {
|
||||
g, ok := digits5x3[r]
|
||||
if !ok {
|
||||
cx += glyphW + gap
|
||||
continue
|
||||
}
|
||||
for row := 0; row < 5; row++ {
|
||||
for col := 0; col < 3; col++ {
|
||||
if g[row][col] != '#' {
|
||||
continue
|
||||
}
|
||||
for iy := 0; iy < sc; iy++ {
|
||||
for ix := 0; ix < sc; ix++ {
|
||||
setOpaque(img, cx+col*sc+ix, y+row*sc+iy, fg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cx += glyphW + gap
|
||||
}
|
||||
}
|
||||
|
||||
func setOpaque(img *image.RGBA, x, y int, c color.RGBA) {
|
||||
if image.Pt(x, y).In(img.Bounds()) {
|
||||
img.SetRGBA(x, y, color.RGBA{c.R, c.G, c.B, 255})
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,8 @@ func cmdChat(args []string) error {
|
||||
full := strings.Builder{}
|
||||
inReason := false
|
||||
var stats *StatsEvent
|
||||
// Raisonnement de la réponse finale (REASONING_ECHO), rangé avec elle.
|
||||
var echo *ReasoningEcho
|
||||
// Print the assistant prefix once; reasoning is shown inline with a tag.
|
||||
fmt.Print(cyan("loki") + " > ")
|
||||
caps := globalCaps()
|
||||
@@ -73,7 +75,8 @@ func cmdChat(args []string) error {
|
||||
msgs = compacted
|
||||
fmt.Println(dim("[contexte compacté pour tenir dans la fenêtre]"))
|
||||
}
|
||||
extra, err := runChat(context.Background(), InjectSkills(msgs, caps), 0.7, caps, func(ev StreamEvent) bool {
|
||||
sent, tools := prepareTurn(msgs, caps)
|
||||
extra, err := runChatTools(context.Background(), sent, tools, 0.7, caps, func(ev StreamEvent) bool {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error()))
|
||||
@@ -89,6 +92,8 @@ func cmdChat(args []string) error {
|
||||
fmt.Println(dim("\n[contexte compacté pour tenir dans la fenêtre]"))
|
||||
case ev.Stats != nil:
|
||||
stats = ev.Stats
|
||||
case ev.Echo != nil:
|
||||
echo = ev.Echo
|
||||
case ev.DropReasoning:
|
||||
// Le tour a « pensé sans agir » : on relance. Impossible d'effacer le
|
||||
// texte déjà imprimé en terminal — on referme juste la ligne reasoning.
|
||||
@@ -154,7 +159,7 @@ func cmdChat(args []string) error {
|
||||
// answer, so next turn the model remembers it already did them instead
|
||||
// of re-invoking the same skill/command from scratch.
|
||||
msgs = append(msgs, extra...)
|
||||
msgs = append(msgs, Message{Role: "assistant", Content: full.String()})
|
||||
msgs = append(msgs, withEcho(Message{Role: "assistant", Content: full.String()}, echo))
|
||||
}
|
||||
}
|
||||
}
|
||||
+757
-71
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,476 @@
|
||||
package loki
|
||||
|
||||
// chat_compact_cont.go — COMPACT_CONTINUATION : la compaction proactive comme
|
||||
// suite du prompt que le moteur a déjà en cache.
|
||||
//
|
||||
// Pourquoi. Aujourd'hui, le résumé d'une compaction part dans une requête à
|
||||
// part : son propre prompt système et une transcription du torse. Le moteur
|
||||
// local la calcule à froid (5 à 46k jetons), puis recalcule à froid le prompt
|
||||
// compacté au message suivant. Sur un 27B, des dizaines de secondes ; sur un
|
||||
// MoE aux experts en RAM, des minutes — à chaque compaction.
|
||||
//
|
||||
// Avec la clé (off par défaut), quand le slot porte selon toute vraisemblance
|
||||
// le prompt de CETTE discussion, la requête de résumé est la requête du tour
|
||||
// telle qu'elle est partie — mêmes messages assemblés par les mêmes fonctions
|
||||
// (turnViewDry, wireMessages, buildChatPayload), mêmes outils, mêmes arguments
|
||||
// du gabarit, même intensité de raisonnement — suivie d'un seul message
|
||||
// utilisateur qui demande le résumé. Le moteur ne calcule que ce message.
|
||||
//
|
||||
// Ce qui ne change pas :
|
||||
// - la compaction elle-même : bornes, archives recall, demande réinjectée,
|
||||
// garantie de réduction et historique rangé sont calculés sur la vue
|
||||
// MODÈLE, comme avant. La vue d'envoi ne sert qu'à construire la requête :
|
||||
// ni prompt système, ni skills, ni bloc projet n'entrent dans l'historique ;
|
||||
// - les exigences du résumé : mêmes règles (compactSummaryRules, plus
|
||||
// codeSummaryRules en mode Code), même budget, même température 0.2 sans
|
||||
// l'échantillonnage du preset, réflexion coupée (enable_thinking=false,
|
||||
// ajouté aux arguments du tour) ;
|
||||
// - le modèle principal, ses tours et leur échantillonnage.
|
||||
//
|
||||
// Ce qui change : le résumeur voit le torse entier dans son format natif (plus
|
||||
// que la transcription, qui raccourcit les résultats d'outils), depuis la
|
||||
// place de l'agent. La frontière lui est désignée par le nombre de messages
|
||||
// gardés et le début du premier d'entre eux ; en cas de doute, il doit plutôt
|
||||
// répéter que laisser tomber, et dater l'état d'avancement à la frontière.
|
||||
//
|
||||
// Repli sur la transcription d'avant, sans rien perdre, au moindre écart :
|
||||
// preset externe, autre requête passée par le slot depuis le dernier tour
|
||||
// (vérificateur, sous-agent, tâche, préchauffage, résumé, bench, client /v1,
|
||||
// autre discussion), moteur relancé sur un autre modèle ou une autre fenêtre,
|
||||
// étape de secours (outils coupés), marge insuffisante dans la fenêtre, vue
|
||||
// désalignée, refus du moteur (gabarit qui exige l'alternance, fenêtre
|
||||
// pleine…), erreur réseau, appel d'outil émis, raisonnement sans résumé,
|
||||
// résumé vide. Un refus du gabarit ou un appel d'outil suspend la continuation
|
||||
// pour ce modèle jusqu'au redémarrage ; deux résumés ratés de suite aussi.
|
||||
//
|
||||
// La clé apporte aussi deux économies sans effet sur le résultat :
|
||||
// - un résumé n'est pas demandé quand même un résumé vide ne passerait pas la
|
||||
// garantie de réduction (compactHopeless, borne exacte) ;
|
||||
// - une compaction refusée parce que le résumé obtenu était trop long n'est
|
||||
// retentée, en début ou fin de tour et entre deux étapes, qu'une fois le
|
||||
// contexte grossi de 10 % sur le même fil (un historique réécrit retente)
|
||||
// — jamais pour le filet réactif, le bouton « compacter », une fenêtre
|
||||
// pleine à 90 % ou plus.
|
||||
//
|
||||
// Réactif (prompt refusé par le moteur), fenêtre pleine en pleine génération,
|
||||
// bouton manuel, tâches, sous-agents, terminal : chemin d'avant, toujours.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// compactContinuationOn : clé posée, moteur local.
|
||||
func compactContinuationOn(cfg map[string]string) bool {
|
||||
if isExternalConfig(cfg) {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(cfg["COMPACT_CONTINUATION"])) {
|
||||
case "on", "1", "true", "yes", "oui":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// compactView : la requête du tour telle qu'elle est partie, pour un résumé
|
||||
// en continuation. wire couvre les n premiers messages de la vue modèle.
|
||||
type compactView struct {
|
||||
wire []Message
|
||||
n int
|
||||
tools []Tool
|
||||
effort string
|
||||
kwargs map[string]any
|
||||
used int // contexte réel (dernier compte du moteur + ce qu'il n'a pas vu)
|
||||
conv string // discussion, pour la télémétrie
|
||||
}
|
||||
|
||||
// compactOpts : options d'une compaction. Nulles : le chemin d'avant, exact.
|
||||
type compactOpts struct {
|
||||
view *compactView // nil : résumé sur transcription
|
||||
precheck bool // pas de résumé quand même un vide serait refusé
|
||||
conv string // discussion, pour la mémoire des refus
|
||||
used int
|
||||
}
|
||||
|
||||
// compactViewLen : la vue s'arrête avant les messages utilisateur de fin (le
|
||||
// nouveau message, ceux de la file, une image montrée à l'étape) : le moteur
|
||||
// ne les a pas en cache, et un second message utilisateur d'affilée ferait
|
||||
// refuser la requête par les gabarits à alternance stricte.
|
||||
func compactViewLen(msgs []Message) int {
|
||||
k := len(msgs)
|
||||
for k > 0 && msgs[k-1].Role == "user" {
|
||||
k--
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// compactOptsFor : options des compactions de début et de fin de tour.
|
||||
func (c *Conversation) compactOptsFor(caps Caps, msgs []Message, used int, conv string) compactOpts {
|
||||
cfg := ReadConfig()
|
||||
if conv == "" || !compactContinuationOn(cfg) {
|
||||
return compactOpts{}
|
||||
}
|
||||
opt := compactOpts{precheck: true, conv: conv, used: used}
|
||||
if used <= 0 || !engineSlotHolds(conv) || compactContDisabled() {
|
||||
return opt
|
||||
}
|
||||
k := compactViewLen(msgs)
|
||||
if k == 0 {
|
||||
return opt
|
||||
}
|
||||
sent, tools := c.turnViewDry(caps, msgs[:k], caps.Agent)
|
||||
_, effort, _, kwargs := turnReasoning(cfg)
|
||||
wire, _ := wireMessages(sent, false, turnEchoPolicy(false))
|
||||
opt.view = &compactView{wire: wire, n: k, tools: tools, effort: effort, kwargs: kwargs, used: used, conv: conv}
|
||||
return opt
|
||||
}
|
||||
|
||||
// compactOptsMid : options d'une compaction entre deux étapes d'un tour
|
||||
// (runChatTools). messages porte déjà le prompt système et le bloc projet ;
|
||||
// pol est la politique de raisonnement renvoyé de la dernière requête.
|
||||
// rescue : étape de secours (outils coupés ou neutralisés), dont le prompt
|
||||
// n'est pas celui du tour.
|
||||
func compactOptsMid(cfg map[string]string, ep chatEndpoint, conv string, messages []Message, used int,
|
||||
tools []Tool, effort string, kwargs map[string]any, pol echoPolicy, rescue bool) compactOpts {
|
||||
if conv == "" || ep.External || !compactContinuationOn(cfg) {
|
||||
return compactOpts{}
|
||||
}
|
||||
opt := compactOpts{precheck: true, conv: conv, used: used}
|
||||
if rescue || used <= 0 || !engineSlotHolds(conv) || compactContDisabled() {
|
||||
return opt
|
||||
}
|
||||
k := compactViewLen(messages)
|
||||
if k == 0 {
|
||||
return opt
|
||||
}
|
||||
wire, _ := wireMessages(messages[:k], false, pol)
|
||||
opt.view = &compactView{wire: wire, n: k, tools: tools, effort: effort, kwargs: kwargs, used: used, conv: conv}
|
||||
return opt
|
||||
}
|
||||
|
||||
// compactMessagesNoted : compactMessagesOpt, plus la mémoire des refus.
|
||||
func compactMessagesNoted(ctx context.Context, msgs []Message, caps Caps, opt compactOpts) ([]Message, bool) {
|
||||
out, changed, refused := compactMessagesOpt(ctx, msgs, caps, opt)
|
||||
if opt.conv != "" {
|
||||
if changed {
|
||||
compactRefusedClear(opt.conv)
|
||||
} else if refused {
|
||||
compactRefusedNote(opt.conv, opt.used, msgs)
|
||||
}
|
||||
}
|
||||
return out, changed
|
||||
}
|
||||
|
||||
// --- refus mémorisés --------------------------------------------------------
|
||||
|
||||
// compactRefusal : une compaction refusée faute de réduction, à at jetons de
|
||||
// contexte, sur un historique de n messages d'empreinte hash. En mémoire
|
||||
// seulement : un redémarrage retente.
|
||||
type compactRefusal struct {
|
||||
at, window int
|
||||
model string
|
||||
n int
|
||||
hash uint64
|
||||
}
|
||||
|
||||
// historyHash : empreinte d'un historique (celle de perfPrefix).
|
||||
func historyHash(msgs []Message) uint64 {
|
||||
if len(msgs) == 0 {
|
||||
return 0
|
||||
}
|
||||
p := perfPrefix(msgs)
|
||||
return p[len(p)-1]
|
||||
}
|
||||
|
||||
var compactRefused struct {
|
||||
mu sync.Mutex
|
||||
m map[string]compactRefusal
|
||||
}
|
||||
|
||||
const compactRefusedMax = 64
|
||||
|
||||
// compactRefusedSkip : la dernière compaction de conv a été refusée, msgs ne
|
||||
// fait que prolonger l'historique d'alors, et le contexte n'a pas grossi de
|
||||
// 10 % depuis. Jamais à 90 % de la fenêtre ou plus ; oublié dès que
|
||||
// l'historique a été réécrit (compaction, édition, régénération, discussion
|
||||
// vidée), que le contexte baisse, ou que le modèle ou la fenêtre changent.
|
||||
func compactRefusedSkip(conv string, used int, msgs []Message) bool {
|
||||
if conv == "" || used <= 0 || !compactContinuationOn(ReadConfig()) {
|
||||
return false
|
||||
}
|
||||
model, window := engineMainNow()
|
||||
compactRefused.mu.Lock()
|
||||
r, ok := compactRefused.m[conv]
|
||||
compactRefused.mu.Unlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if r.model != model || r.window != window || used < r.at || used >= r.at+r.at/10 ||
|
||||
len(msgs) < r.n || historyHash(msgs[:r.n]) != r.hash {
|
||||
compactRefusedClear(conv)
|
||||
return false
|
||||
}
|
||||
if used*10 >= window*9 {
|
||||
return false
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[compact] pas de nouvel essai : refusée à %d jetons, contexte à %d\n", r.at, used)
|
||||
return true
|
||||
}
|
||||
|
||||
func compactRefusedNote(conv string, used int, msgs []Message) {
|
||||
if conv == "" || used <= 0 {
|
||||
return
|
||||
}
|
||||
model, window := engineMainNow()
|
||||
hash := historyHash(msgs)
|
||||
compactRefused.mu.Lock()
|
||||
defer compactRefused.mu.Unlock()
|
||||
if compactRefused.m == nil {
|
||||
compactRefused.m = map[string]compactRefusal{}
|
||||
}
|
||||
if _, ok := compactRefused.m[conv]; !ok && len(compactRefused.m) >= compactRefusedMax {
|
||||
for k := range compactRefused.m {
|
||||
delete(compactRefused.m, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
compactRefused.m[conv] = compactRefusal{at: used, window: window, model: model, n: len(msgs), hash: hash}
|
||||
}
|
||||
|
||||
func compactRefusedClear(conv string) {
|
||||
compactRefused.mu.Lock()
|
||||
delete(compactRefused.m, conv)
|
||||
compactRefused.mu.Unlock()
|
||||
}
|
||||
|
||||
// --- échecs par modèle ------------------------------------------------------
|
||||
|
||||
// compactContFails : échecs de la continuation par modèle, jusqu'au
|
||||
// redémarrage. À compactContMaxFails, elle n'est plus tentée pour ce modèle.
|
||||
var compactContFails struct {
|
||||
mu sync.Mutex
|
||||
m map[string]int
|
||||
}
|
||||
|
||||
const compactContMaxFails = 2
|
||||
|
||||
func compactContDisabled() bool {
|
||||
model, _ := engineMainNow()
|
||||
compactContFails.mu.Lock()
|
||||
defer compactContFails.mu.Unlock()
|
||||
return compactContFails.m[model] >= compactContMaxFails
|
||||
}
|
||||
|
||||
// compactContFail : hard = refus du gabarit ou appel d'outil, qui se
|
||||
// reproduiraient à l'identique : suspendue tout de suite.
|
||||
func compactContFail(hard bool) {
|
||||
model, _ := engineMainNow()
|
||||
compactContFails.mu.Lock()
|
||||
defer compactContFails.mu.Unlock()
|
||||
if compactContFails.m == nil {
|
||||
compactContFails.m = map[string]int{}
|
||||
}
|
||||
n := compactContFails.m[model] + 1
|
||||
if hard {
|
||||
n = compactContMaxFails
|
||||
}
|
||||
if n >= compactContMaxFails && compactContFails.m[model] < compactContMaxFails {
|
||||
fmt.Fprintf(os.Stderr, "[compact] continuation suspendue pour %s jusqu'au redémarrage : résumés sur transcription\n", model)
|
||||
}
|
||||
compactContFails.m[model] = n
|
||||
}
|
||||
|
||||
func compactContOK() {
|
||||
model, _ := engineMainNow()
|
||||
compactContFails.mu.Lock()
|
||||
delete(compactContFails.m, model)
|
||||
compactContFails.mu.Unlock()
|
||||
}
|
||||
|
||||
// --- la requête -------------------------------------------------------------
|
||||
|
||||
// compactWireIndex : position dans view.wire du message msgs[i] de la vue
|
||||
// modèle (len(view.wire) pour i == view.n : toute la vue), -1 si les deux vues
|
||||
// ne se correspondent pas. L'assemblage n'ajoute ou ne déplace que des
|
||||
// messages système (prompt, skills, bloc projet, rappel des pages lues) ; les
|
||||
// autres se suivent un pour un, dans le même ordre — vérifié rôle par rôle,
|
||||
// appel par appel, plutôt que supposé.
|
||||
func compactWireIndex(msgs []Message, view *compactView, i int) int {
|
||||
if view == nil || view.n > len(msgs) || i < 0 || i > view.n {
|
||||
return -1
|
||||
}
|
||||
var a, b []int
|
||||
for j, m := range msgs[:view.n] {
|
||||
if m.Role != "system" {
|
||||
a = append(a, j)
|
||||
}
|
||||
}
|
||||
for j, m := range view.wire {
|
||||
if m.Role != "system" {
|
||||
b = append(b, j)
|
||||
}
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
return -1
|
||||
}
|
||||
at := -1
|
||||
for j := range a {
|
||||
m, w := msgs[a[j]], view.wire[b[j]]
|
||||
if m.Role != w.Role || m.ToolCallID != w.ToolCallID || len(m.ToolCalls) != len(w.ToolCalls) {
|
||||
return -1
|
||||
}
|
||||
if a[j] == i {
|
||||
at = b[j]
|
||||
}
|
||||
}
|
||||
if i == view.n {
|
||||
return len(view.wire)
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// clipRunes : s sur une ligne, coupé à n caractères.
|
||||
func clipRunes(s string, n int) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:n]) + "…"
|
||||
}
|
||||
|
||||
// compactBoundaryDesc désigne le premier message gardé, m (vue modèle), qui
|
||||
// est wire[wi] : son rôle, et l'appel d'outil ou le début du texte. Un début
|
||||
// déjà vu plus haut est allongé, pour rester sans ambiguïté.
|
||||
func compactBoundaryDesc(wire []Message, wi int, m Message) string {
|
||||
if m.Role == "assistant" && strings.TrimSpace(msgText(m)) == "" && len(m.ToolCalls) > 0 {
|
||||
f := m.ToolCalls[0].Function
|
||||
return fmt.Sprintf("your call to the tool %s(%s)", f.Name, clipRunes(f.Arguments, 120))
|
||||
}
|
||||
who := "the user message"
|
||||
switch m.Role {
|
||||
case "assistant":
|
||||
who = "your message"
|
||||
case "tool":
|
||||
who = "the tool result"
|
||||
}
|
||||
m, _ = withoutCtxUpdate(m)
|
||||
text := msgText(m)
|
||||
snip := clipRunes(text, 80)
|
||||
for _, w := range wire[:wi] {
|
||||
if strings.Contains(strings.Join(strings.Fields(msgText(w)), " "), strings.TrimSuffix(snip, "…")) {
|
||||
snip = clipRunes(text, 200)
|
||||
break
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s that starts with «%s»", who, snip)
|
||||
}
|
||||
|
||||
// compactContPrefix ouvre la demande de résumé : jamais rangée, elle ne vit
|
||||
// que dans la requête.
|
||||
const compactContPrefix = "[COMPACTION]"
|
||||
|
||||
// compactContInstruction : la demande ajoutée en fin de requête. kept =
|
||||
// messages gardés tels quels à partir de la frontière (0 : tout est résumé).
|
||||
func compactContInstruction(kept int, desc string, code bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(compactContPrefix)
|
||||
b.WriteString(" Pause the task: do not answer and do not call any tool. The context is full: the older part of this conversation will be replaced by the summary you write now, and your past reasoning will be lost — this summary is the only memory kept of that part.\n")
|
||||
if kept > 0 {
|
||||
fmt.Fprintf(&b, "Summarize every message BEFORE %s. That message and the ones after it (the last %d) stay verbatim after your summary: mention them briefly at most, but never leave out anything that comes before. Write the STATE OF PROGRESS as of that message, not later.\n", desc, kept)
|
||||
} else {
|
||||
b.WriteString("Summarize the whole conversation above.\n")
|
||||
}
|
||||
b.WriteString("Leave out the system instructions and any <project_context> or <context_update> block: they are sent again separately.\n\n")
|
||||
b.WriteString(compactSummaryRules)
|
||||
if code {
|
||||
b.WriteString(codeSummaryRules)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// summarizeContinuation demande le résumé du torse [.., tailStart) en
|
||||
// prolongeant la requête du tour (view). Toute erreur fait retomber
|
||||
// l'appelant sur la transcription.
|
||||
func summarizeContinuation(ctx context.Context, msgs []Message, tailStart int, view *compactView, code bool) (string, error) {
|
||||
ep := resolveChatEndpoint()
|
||||
if ep.External {
|
||||
return "", errors.New("preset externe")
|
||||
}
|
||||
if tailStart > view.n {
|
||||
return "", errors.New("frontière hors de la vue")
|
||||
}
|
||||
// Relu à l'envoi : une requête partie depuis la construction de la vue
|
||||
// aurait pris le slot.
|
||||
if !engineSlotHolds(view.conv) {
|
||||
return "", errors.New("slot repris entre-temps")
|
||||
}
|
||||
wi := compactWireIndex(msgs, view, tailStart)
|
||||
if wi < 0 {
|
||||
return "", errors.New("vue d'envoi désalignée")
|
||||
}
|
||||
kept, desc := len(view.wire)-wi, ""
|
||||
if kept > 0 {
|
||||
desc = compactBoundaryDesc(view.wire, wi, msgs[tailStart])
|
||||
}
|
||||
instr := Message{Role: "user", Content: compactContInstruction(kept, desc, code)}
|
||||
// Marge : contexte réel, la demande, le résumé, et 5 % de la fenêtre pour
|
||||
// ce que l'estimation ne voit pas.
|
||||
if need := view.used + msgTokens(instr) + compactSummaryBudget() + ctxWindow()*5/100; need > ctxWindow() {
|
||||
return "", fmt.Errorf("marge insuffisante : %d jetons pour une fenêtre de %d", need, ctxWindow())
|
||||
}
|
||||
// Arguments du gabarit du tour, réflexion coupée : sur les gabarits
|
||||
// hybrides (Qwen3…), enable_thinking ne change que l'ouverture de la
|
||||
// réponse ; reasoning_effort, rendu dans le préfixe par gpt-oss, reste
|
||||
// celui du tour.
|
||||
kwargs := map[string]any{}
|
||||
for k, v := range view.kwargs {
|
||||
kwargs[k] = v
|
||||
}
|
||||
kwargs["enable_thinking"] = false
|
||||
sent := append(append([]Message(nil), view.wire...), instr)
|
||||
// tool_choice « none » : les outils restent rendus dans le prompt (le
|
||||
// préfixe en cache tient), aucun appel n'est attendu.
|
||||
payload := buildChatPayload(ep, sent, 0.2, chatPayloadOpts{
|
||||
tools: view.tools, toolChoiceNone: true, effort: view.effort, kwargs: kwargs, noSampling: true,
|
||||
})
|
||||
payload["stream"] = false
|
||||
delete(payload, "stream_options")
|
||||
payload["max_tokens"] = compactSummaryBudget()
|
||||
// SIDE_SLOT : elle prolonge le prompt du slot de la discussion, elle y va.
|
||||
setEngineSlot(payload, engineSlotFor(ep, true))
|
||||
ch, err := postSummary(ctx, ep, payload, perfTag{kind: perfCompact, conv: view.conv})
|
||||
if err != nil {
|
||||
// Refus de la requête elle-même (gabarit à alternance stricte, champ
|
||||
// inconnu…) : il se reproduirait à chaque compaction. Une fenêtre
|
||||
// pleine, un moteur en chargement ou une coupure réseau, non.
|
||||
var he *summaryHTTPError
|
||||
if errors.As(err, &he) && (he.status == 400 || he.status == 422 || he.status == 500) && !contextOverflow(he.body, nil) {
|
||||
compactContFail(true)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if len(ch.Message.ToolCalls) > 0 {
|
||||
compactContFail(true)
|
||||
return "", errors.New("appel d'outil émis au lieu d'un résumé")
|
||||
}
|
||||
if textualToolCallSnippet(ch.Message.Content) != "" {
|
||||
compactContFail(false)
|
||||
return "", errors.New("appel d'outil écrit en texte")
|
||||
}
|
||||
s, err := cleanSummary(ch.Message.Content, ch.Message.ReasoningContent, ch.FinishReason)
|
||||
if err != nil {
|
||||
compactContFail(false)
|
||||
return "", err
|
||||
}
|
||||
if summaryLooksEmpty(s) {
|
||||
compactContFail(false)
|
||||
return "", errors.New("résumé vide")
|
||||
}
|
||||
compactContOK()
|
||||
return s, nil
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// oldCompactorSys : le prompt du résumeur tel qu'il était avant le partage de
|
||||
// ses règles avec la continuation — copie figée, à l'octet près.
|
||||
const oldCompactorSys = `You are a context compactor. You are given the transcript of the older turns of a conversation between a user and an AI assistant (with its tools). The PURPOSE of your summary is to let the conversation continue in a fresh, smaller context WITHOUT losing any information that is useful or important to understand what came before and keep working — preserve everything that matters, drop only what is redundant.
|
||||
|
||||
The assistant is MID-TASK: it will read your summary and must resume exactly where it left off, WITHOUT redoing work it has already done. Its own internal reasoning is NOT part of the transcript and is lost — your summary is the only memory it keeps.
|
||||
|
||||
Summarize densely and faithfully, keeping ONLY the essentials:
|
||||
- The user's CURRENT request, goal(s) and constraints
|
||||
- FINDINGS: the concrete information already gathered — facts, figures, dates, names, URLs, file paths, values, config. This is the most important part: whatever is not here is lost and will have to be looked up again.
|
||||
- Sources already consulted (URLs opened, files read, commands run) — so they are not consulted a second time
|
||||
- Decisions made and established facts
|
||||
- STATE OF PROGRESS: what is already answered, what is still missing, and the next concrete step
|
||||
Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Be as concise as you can WHILE keeping every fact, decision and still-open task: a detail you drop here is lost for good, so when in doubt keep it. This is a dense compression summary, not a report. Always write ACTUAL prose sentences/bullets — never answer with just an id or a reference.
|
||||
Write the summary in the SAME language as the conversation.`
|
||||
|
||||
const contSummary = "- L'utilisateur prépare un voyage à Lyon ; horaires trouvés : départ 8 h 12, arrivée 10 h 05."
|
||||
|
||||
// moteurCont : faux llama-server. Tours streamés : « ok. ». Résumés :
|
||||
// contSummary ; pour la continuation, contStatus (refus) ou contReply s'ils
|
||||
// sont posés.
|
||||
type moteurCont struct {
|
||||
mu sync.Mutex
|
||||
bodies []string
|
||||
contStatus int
|
||||
contReply map[string]any
|
||||
}
|
||||
|
||||
func (m *moteurCont) start(t *testing.T) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var buf bytes.Buffer
|
||||
_, _ = buf.ReadFrom(r.Body)
|
||||
body := buf.String()
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
sendJSON(w, 200, map[string]any{"status": "ok"})
|
||||
return
|
||||
case "/props":
|
||||
sendJSON(w, 200, map[string]any{"total_slots": 1, "build_info": "b9000-x"})
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.bodies = append(m.bodies, body)
|
||||
status, reply := m.contStatus, m.contReply
|
||||
m.mu.Unlock()
|
||||
if strings.Contains(body, `"stream":true`) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(sseChunk("ok.") + sseFinal("stop", 100, 2) + "data: [DONE]\n\n"))
|
||||
return
|
||||
}
|
||||
if isContBody(body) {
|
||||
if status != 0 {
|
||||
sendJSON(w, status, map[string]any{"error": map[string]any{"message": "template error"}})
|
||||
return
|
||||
}
|
||||
if reply != nil {
|
||||
sendJSON(w, 200, reply)
|
||||
return
|
||||
}
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"choices": []any{map[string]any{"message": map[string]any{"content": contSummary}, "finish_reason": "stop"}},
|
||||
"usage": map[string]any{"prompt_tokens": 900, "completion_tokens": 40},
|
||||
"timings": map[string]any{"prompt_n": 30, "cache_n": 870, "predicted_n": 40},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func isContBody(body string) bool {
|
||||
var p struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(body), &p)
|
||||
n := len(p.Messages)
|
||||
return n > 0 && strings.HasPrefix(msgText(p.Messages[n-1]), compactContPrefix)
|
||||
}
|
||||
|
||||
func isTranscriptBody(body string) bool {
|
||||
var p struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(body), &p)
|
||||
return len(p.Messages) == 2 && p.Messages[0].Role == "system" &&
|
||||
strings.HasPrefix(msgText(p.Messages[0]), "You are a context compactor.")
|
||||
}
|
||||
|
||||
func (m *moteurCont) all() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return append([]string(nil), m.bodies...)
|
||||
}
|
||||
|
||||
// contReset : état global de la continuation remis à neuf.
|
||||
func contReset(t *testing.T) {
|
||||
reset := func() {
|
||||
engineGate.mu.Lock()
|
||||
engineGate.main = engineMainStamp{}
|
||||
engineGate.mu.Unlock()
|
||||
compactContFails.mu.Lock()
|
||||
compactContFails.m = nil
|
||||
compactContFails.mu.Unlock()
|
||||
compactRefused.mu.Lock()
|
||||
compactRefused.m = nil
|
||||
compactRefused.mu.Unlock()
|
||||
}
|
||||
reset()
|
||||
t.Cleanup(reset)
|
||||
}
|
||||
|
||||
func contSetup(t *testing.T, keys map[string]string) *moteurCont {
|
||||
t.Helper()
|
||||
withWorkspace(t)
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
setProjectOverride("")
|
||||
t.Cleanup(func() {
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
setProjectOverride("")
|
||||
})
|
||||
ensureDefaultProject()
|
||||
freshTplProbe(t)
|
||||
convEnsureActive()
|
||||
contReset(t)
|
||||
if err := SetConfigKey("MODEL", "/models/"+echoTestModel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetConfigKey("CTX", "8192"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, v := range keys {
|
||||
if err := SetConfigKey(k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m := &moteurCont{}
|
||||
m.start(t)
|
||||
return m
|
||||
}
|
||||
|
||||
// contHistory : une longue discussion, de quoi compacter.
|
||||
func contHistory() []Message {
|
||||
var h []Message
|
||||
for i := 0; i < 10; i++ {
|
||||
h = append(h, um("question "+string(rune('a'+i))+" "+strings.Repeat("u", 1500)),
|
||||
am("réponse "+string(rune('a'+i))+" "+strings.Repeat("r", 1500)))
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// contTwoTurns : un premier tour sans compaction (il pose le tampon du slot),
|
||||
// puis un second dont le début compacte. Rend les corps envoyés pendant le
|
||||
// second tour, et celui du premier tour.
|
||||
func contTwoTurns(t *testing.T, m *moteurCont, c *Conversation, caps Caps) (first string, second []string) {
|
||||
t.Helper()
|
||||
c.mu.Lock()
|
||||
c.Messages = contHistory()
|
||||
c.CtxUsed, c.ctxUsedLen = 1000, len(c.Messages)
|
||||
c.mu.Unlock()
|
||||
prewarmTurn(t, c, caps, "première demande")
|
||||
bodies := m.all()
|
||||
if len(bodies) != 1 {
|
||||
t.Fatalf("premier tour : 1 requête attendue, %d", len(bodies))
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.CtxUsed, c.ctxUsedLen = 6200, len(c.Messages)
|
||||
c.mu.Unlock()
|
||||
prewarmTurn(t, c, caps, "deuxième demande")
|
||||
return bodies[0], m.all()[1:]
|
||||
}
|
||||
|
||||
// Sans la clé : la requête de résumé est celle d'avant — prompt du résumeur
|
||||
// identique à l'octet près, transcription, aucun champ de plus — et aucun
|
||||
// tampon de slot n'est posé.
|
||||
func TestCompactContDefautIdentique(t *testing.T) {
|
||||
m := contSetup(t, map[string]string{"TOP_P": "0.9", "REASONING_EFFORT": "high"})
|
||||
c := newTestConv()
|
||||
_, second := contTwoTurns(t, m, c, Caps{Agent: true})
|
||||
if len(second) != 2 || !isTranscriptBody(second[0]) {
|
||||
t.Fatalf("sans la clé : résumé sur transcription puis tour attendus, %d requêtes", len(second))
|
||||
}
|
||||
var p map[string]any
|
||||
if err := json.Unmarshal([]byte(second[0]), &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]any{"model": p["model"], "stream": false, "temperature": 0.2, "max_tokens": float64(700),
|
||||
"chat_template_kwargs": map[string]any{"enable_thinking": false}, "messages": p["messages"]}
|
||||
if !reflect.DeepEqual(p, want) {
|
||||
t.Fatalf("champs du résumé changés : %v", p)
|
||||
}
|
||||
if sys := p["messages"].([]any)[0].(map[string]any)["content"]; sys != oldCompactorSys {
|
||||
t.Fatalf("prompt du résumeur changé :\n%v", sys)
|
||||
}
|
||||
engineGate.mu.Lock()
|
||||
stamp := engineGate.main
|
||||
engineGate.mu.Unlock()
|
||||
if stamp.seq != 0 {
|
||||
t.Fatal("tampon de slot posé sans la clé")
|
||||
}
|
||||
if compactContinuationOn(ReadConfig()) || compactRefusedSkip(convActiveID(), 7000, nil) {
|
||||
t.Fatal("continuation ou mémoire des refus active sans la clé")
|
||||
}
|
||||
}
|
||||
|
||||
// Avec la clé : la requête de résumé prolonge EXACTEMENT celle du premier tour
|
||||
// (même préfixe, mêmes outils et réglages du gabarit), plus la réponse rangée
|
||||
// et une seule demande de résumé ; sans l'échantillonnage du preset ; et rien
|
||||
// d'injecté n'entre dans l'historique.
|
||||
func TestCompactContPrefixeEtHistorique(t *testing.T) {
|
||||
m := contSetup(t, map[string]string{"COMPACT_CONTINUATION": "on", "TOP_P": "0.9", "REASONING_EFFORT": "high"})
|
||||
c := newTestConv()
|
||||
first, second := contTwoTurns(t, m, c, Caps{Agent: true})
|
||||
if len(second) != 2 || !isContBody(second[0]) {
|
||||
t.Fatalf("continuation puis tour attendus, %d requêtes (transcription : %v)", len(second), len(second) > 0 && isTranscriptBody(second[0]))
|
||||
}
|
||||
var f, s map[string]any
|
||||
if err := json.Unmarshal([]byte(first), &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(second[0]), &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fm, sm := f["messages"].([]any), s["messages"].([]any)
|
||||
if len(sm) != len(fm)+2 {
|
||||
t.Fatalf("continuation : %d messages, premier tour %d (+ réponse + demande attendus)", len(sm), len(fm))
|
||||
}
|
||||
if !reflect.DeepEqual(sm[:len(fm)], fm) {
|
||||
t.Fatal("la continuation ne prolonge pas la requête du premier tour")
|
||||
}
|
||||
if a := sm[len(fm)].(map[string]any); a["role"] != "assistant" || a["content"] != "ok." {
|
||||
t.Fatalf("réponse rangée attendue après le préfixe : %v", a)
|
||||
}
|
||||
last := sm[len(sm)-1].(map[string]any)
|
||||
if last["role"] != "user" || !strings.Contains(last["content"].(string), "STATE OF PROGRESS") {
|
||||
t.Fatalf("demande de résumé : %v", last)
|
||||
}
|
||||
for _, k := range []string{"tools", "parallel_tool_calls", "reasoning_effort", "model"} {
|
||||
if !reflect.DeepEqual(f[k], s[k]) {
|
||||
t.Fatalf("champ %s : tour %v, continuation %v", k, f[k], s[k])
|
||||
}
|
||||
}
|
||||
if s["tool_choice"] != "none" || s["stream"] != false || s["max_tokens"] != float64(700) ||
|
||||
s["temperature"] != 0.2 || s["top_p"] != nil || s["stream_options"] != nil {
|
||||
t.Fatalf("champs de réponse : %v", s)
|
||||
}
|
||||
if f["top_p"] != 0.9 {
|
||||
t.Fatal("le tour lui-même doit garder l'échantillonnage du preset")
|
||||
}
|
||||
kw, _ := s["chat_template_kwargs"].(map[string]any)
|
||||
if kw["enable_thinking"] != false || kw["reasoning_effort"] != "high" {
|
||||
t.Fatalf("arguments du gabarit : %v", kw)
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
found := false
|
||||
for _, msg := range c.Messages {
|
||||
txt := msgText(msg)
|
||||
if msg.Role == "system" || strings.HasPrefix(txt, compactContPrefix) || strings.Contains(txt, "<project_context>") {
|
||||
t.Fatalf("message injecté rangé dans l'historique : %s %.80q", msg.Role, txt)
|
||||
}
|
||||
if strings.Contains(txt, contSummary) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("résumé absent de l'historique compacté")
|
||||
}
|
||||
kinds := false
|
||||
for _, r := range perfLog.snapshot() {
|
||||
if r.Kind == perfCompact && r.Conv == convActiveID() {
|
||||
kinds = true
|
||||
}
|
||||
}
|
||||
if !kinds {
|
||||
t.Fatal("aucune complétion kind=compact rattachée à la discussion")
|
||||
}
|
||||
}
|
||||
|
||||
// contViewOpts : options en cours de tour pour msgs, slot tamponné pour conv.
|
||||
func contViewOpts(t *testing.T, msgs []Message, used int) compactOpts {
|
||||
t.Helper()
|
||||
end, seq := engineRequestBegin(nil)
|
||||
end()
|
||||
engineMarkMain(seq, "conv-test")
|
||||
return compactOptsMid(ReadConfig(), resolveChatEndpoint(), "conv-test", msgs, used, nil, "", nil, echoPolicy{}, false)
|
||||
}
|
||||
|
||||
func contMidMsgs() []Message {
|
||||
return append([]Message{{Role: "system", Content: "SYS"}}, contHistory()...)
|
||||
}
|
||||
|
||||
// Les replis : refus du moteur (gabarit), appel d'outil émis, marge
|
||||
// insuffisante, slot pris entre-temps — la compaction passe par la
|
||||
// transcription et réussit quand même. Un refus du gabarit ou un appel
|
||||
// d'outil suspend la continuation pour ce modèle.
|
||||
func TestCompactContReplis(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(m *moteurCont)
|
||||
used int
|
||||
stale bool
|
||||
wantCont bool
|
||||
disabled bool
|
||||
}{
|
||||
{"gabarit 500", func(m *moteurCont) { m.contStatus = 500 }, 5000, false, true, true},
|
||||
{"appel d'outil", func(m *moteurCont) {
|
||||
m.contReply = map[string]any{"choices": []any{map[string]any{"message": map[string]any{
|
||||
"content": "", "tool_calls": []any{map[string]any{"id": "x", "type": "function"}}}, "finish_reason": "tool_calls"}}}
|
||||
}, 5000, false, true, true},
|
||||
{"raisonnement seul", func(m *moteurCont) {
|
||||
m.contReply = map[string]any{"choices": []any{map[string]any{"message": map[string]any{
|
||||
"content": "", "reasoning_content": "je réfléchis"}, "finish_reason": "length"}}}
|
||||
}, 5000, false, true, false},
|
||||
{"marge", func(*moteurCont) {}, 7500, false, false, false},
|
||||
{"slot pris", func(*moteurCont) {}, 5000, true, false, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := contSetup(t, map[string]string{"COMPACT_CONTINUATION": "on"})
|
||||
tc.setup(m)
|
||||
msgs := contMidMsgs()
|
||||
opt := contViewOpts(t, msgs, tc.used)
|
||||
if opt.view == nil {
|
||||
t.Fatal("vue attendue")
|
||||
}
|
||||
if tc.stale {
|
||||
engineRequestStart()()
|
||||
}
|
||||
out, changed := compactMessagesNoted(context.Background(), msgs, Caps{}, opt)
|
||||
if !changed {
|
||||
t.Fatal("compaction refusée")
|
||||
}
|
||||
if out[0].Role != "system" || msgText(out[0]) != "SYS" || !strings.Contains(msgText(out[1]), contSummary) {
|
||||
t.Fatalf("sortie inattendue : %v / %.80q", out[0], msgText(out[1]))
|
||||
}
|
||||
bodies := m.all()
|
||||
cont := len(bodies) == 2 && isContBody(bodies[0]) && isTranscriptBody(bodies[1])
|
||||
direct := len(bodies) == 1 && isTranscriptBody(bodies[0])
|
||||
if tc.wantCont && !cont || !tc.wantCont && !direct {
|
||||
t.Fatalf("requêtes inattendues (%d)", len(bodies))
|
||||
}
|
||||
if compactContDisabled() != tc.disabled {
|
||||
t.Fatalf("suspension pour le modèle : %v, attendu %v", compactContDisabled(), tc.disabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Deux résumés ratés de suite suspendent la continuation ; un réussi remet le
|
||||
// compte à zéro.
|
||||
func TestCompactContSuspension(t *testing.T) {
|
||||
contSetup(t, map[string]string{"COMPACT_CONTINUATION": "on"})
|
||||
compactContFail(false)
|
||||
if compactContDisabled() {
|
||||
t.Fatal("suspendue après un seul échec")
|
||||
}
|
||||
compactContOK()
|
||||
compactContFail(false)
|
||||
if compactContDisabled() {
|
||||
t.Fatal("le succès n'a pas remis le compte à zéro")
|
||||
}
|
||||
compactContFail(false)
|
||||
if !compactContDisabled() {
|
||||
t.Fatal("pas suspendue après deux échecs")
|
||||
}
|
||||
if o := contViewOpts(t, contMidMsgs(), 5000); o.view != nil || !o.precheck {
|
||||
t.Fatal("vue construite pour un modèle suspendu")
|
||||
}
|
||||
}
|
||||
|
||||
// Le tampon du slot : posé par une étape d'un tour de discussion, perdu dès
|
||||
// qu'une autre requête part (résumé, vérification, préchauffage…), ou que le
|
||||
// modèle ou la fenêtre changent.
|
||||
func TestEngineSlotHolds(t *testing.T) {
|
||||
testHome(t)
|
||||
contReset(t)
|
||||
if err := SetConfigKey("MODEL", "/models/a.gguf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mark := func() {
|
||||
end, seq := engineRequestBegin(nil)
|
||||
end()
|
||||
engineMarkMain(seq, "c1")
|
||||
}
|
||||
mark()
|
||||
if !engineSlotHolds("c1") || engineSlotHolds("c2") || engineSlotHolds("") {
|
||||
t.Fatal("tampon mal lu")
|
||||
}
|
||||
engineRequestStart()()
|
||||
if engineSlotHolds("c1") {
|
||||
t.Fatal("tampon gardé après une autre requête")
|
||||
}
|
||||
// Une requête partie entre le départ et l'acceptation : pas de tampon.
|
||||
end, seq := engineRequestBegin(nil)
|
||||
end()
|
||||
engineRequestStart()()
|
||||
engineMarkMain(seq, "c1")
|
||||
if engineSlotHolds("c1") {
|
||||
t.Fatal("tampon posé pour une requête dépassée")
|
||||
}
|
||||
mark()
|
||||
p := &prewarmRun{cancel: func() {}}
|
||||
if endP, ok := prewarmBegin(p); ok {
|
||||
endP()
|
||||
}
|
||||
if engineSlotHolds("c1") {
|
||||
t.Fatal("tampon gardé après un préchauffage")
|
||||
}
|
||||
mark()
|
||||
if err := SetConfigKey("MODEL", "/models/b.gguf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if engineSlotHolds("c1") {
|
||||
t.Fatal("tampon gardé après un changement de modèle")
|
||||
}
|
||||
}
|
||||
|
||||
// Une étape d'un sous-agent ou d'une vérification ne pose pas de tampon :
|
||||
// après elle, la compaction de fin de tour passe par la transcription.
|
||||
func TestCompactContPasApresVerification(t *testing.T) {
|
||||
contSetup(t, map[string]string{"COMPACT_CONTINUATION": "on"})
|
||||
end, seq := engineRequestBegin(nil)
|
||||
end()
|
||||
engineMarkMain(seq, convActiveID())
|
||||
ctx := withPerf(context.Background(), perfMain, convActiveID())
|
||||
if _, err := runChat(withPerfKind(ctx, perfVerify), []Message{um("vérifie")}, 0.2, Caps{}, func(StreamEvent) bool { return true }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if engineSlotHolds(convActiveID()) {
|
||||
t.Fatal("tampon gardé après une vérification")
|
||||
}
|
||||
if _, err := runChat(ctx, []Message{um("suite")}, 0.2, Caps{}, func(StreamEvent) bool { return true }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !engineSlotHolds(convActiveID()) {
|
||||
t.Fatal("une étape du tour devrait poser le tampon")
|
||||
}
|
||||
}
|
||||
|
||||
// La correspondance des deux vues : messages système ajoutés ou déplacés,
|
||||
// les autres un pour un ; tout écart rend -1.
|
||||
func TestCompactWireIndex(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: memReminderPrefix + " pages"},
|
||||
um("a"), atc("bash"), tm("r"), am("b"), um("c"),
|
||||
}
|
||||
wire := normalizeSystemMessages(append([]Message{{Role: "system", Content: "SYS"}, {Role: "system", Content: projectContextPrefix + " p"}}, msgs[:5]...))
|
||||
v := &compactView{wire: wire, n: 5}
|
||||
if wire[0].Role != "system" || len(wire) != 5 {
|
||||
t.Fatalf("vue d'envoi : %v", wire)
|
||||
}
|
||||
for i, want := range map[int]int{1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 0: -1, 6: -1} {
|
||||
if got := compactWireIndex(msgs, v, i); got != want {
|
||||
t.Errorf("index %d : %d, attendu %d", i, got, want)
|
||||
}
|
||||
}
|
||||
bad := append([]Message(nil), wire...)
|
||||
bad[3] = am("x")
|
||||
if compactWireIndex(msgs, &compactView{wire: bad, n: 5}, 2) != -1 {
|
||||
t.Fatal("vue désalignée acceptée")
|
||||
}
|
||||
if compactWireIndex(msgs, &compactView{wire: wire[:4], n: 5}, 2) != -1 {
|
||||
t.Fatal("vue tronquée acceptée")
|
||||
}
|
||||
}
|
||||
|
||||
// La frontière désignée : appel d'outil par son nom et ses arguments, texte
|
||||
// par son début, allongé s'il est déjà apparu plus haut.
|
||||
func TestCompactBoundaryDesc(t *testing.T) {
|
||||
call := Message{Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Function: ToolCallFunc{Name: "web_read", Arguments: `{"url":"https://exemple.fr"}`}}}}
|
||||
if d := compactBoundaryDesc(nil, 0, call); !strings.Contains(d, `web_read({"url":"https://exemple.fr"})`) {
|
||||
t.Fatalf("appel d'outil : %s", d)
|
||||
}
|
||||
long := strings.Repeat("même début ", 10) + "fin unique"
|
||||
wire := []Message{um(strings.Repeat("même début ", 10) + "autre fin"), um(long)}
|
||||
d := compactBoundaryDesc(wire, 1, wire[1])
|
||||
if !strings.Contains(d, "fin unique") {
|
||||
t.Fatalf("début ambigu non allongé : %s", d)
|
||||
}
|
||||
if d := compactBoundaryDesc(nil, 0, um("court")); d != "the user message that starts with «court»" {
|
||||
t.Fatalf("texte : %s", d)
|
||||
}
|
||||
}
|
||||
|
||||
// La demande de résumé reprend les règles du résumeur, et celles du mode Code.
|
||||
func TestCompactContInstruction(t *testing.T) {
|
||||
s := compactContInstruction(3, "the user message that starts with «x»", true)
|
||||
for _, want := range []string{compactContPrefix, compactSummaryRules, codeSummaryRules, "the last 3", "BEFORE the user message"} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Fatalf("demande sans %q", want)
|
||||
}
|
||||
}
|
||||
if s := compactContInstruction(0, "", false); !strings.Contains(s, "whole conversation") || strings.Contains(s, "CODING") {
|
||||
t.Fatal("demande sans frontière")
|
||||
}
|
||||
}
|
||||
|
||||
// Pré-contrôle : quand même un résumé vide ne réduirait pas de 20 %, aucun
|
||||
// résumé n'est demandé ni aucun bloc archivé ; sans lui (sans la clé), la
|
||||
// requête part comme avant, pour être refusée ensuite.
|
||||
func TestCompactHopeless(t *testing.T) {
|
||||
m := contSetup(t, nil)
|
||||
// Torse minuscule, queue énorme : rien à gagner.
|
||||
msgs := []Message{um("a"), am("b"), um("c"), am(strings.Repeat("r", 40000))}
|
||||
head, tailStart := compactBounds(msgs, int(float64(estimateTokens(msgs))*compactTailFrac))
|
||||
if !compactHopeless(msgs, head, tailStart) {
|
||||
t.Fatal("cas sans issue non reconnu")
|
||||
}
|
||||
if _, changed, _ := compactMessagesOpt(context.Background(), msgs, Caps{Agent: true}, compactOpts{precheck: true}); changed {
|
||||
t.Fatal("compaction acceptée")
|
||||
}
|
||||
if n := len(m.all()); n != 0 {
|
||||
t.Fatalf("résumé demandé malgré le pré-contrôle (%d requêtes)", n)
|
||||
}
|
||||
_, changed, refused := compactMessagesOpt(context.Background(), msgs, Caps{Agent: true}, compactOpts{})
|
||||
if changed || !refused || len(m.all()) != 1 {
|
||||
t.Fatalf("sans pré-contrôle : changed=%v refused=%v requêtes=%d", changed, refused, len(m.all()))
|
||||
}
|
||||
// Un refus après résumé est noté ; il n'arrête ni le filet réactif ni le
|
||||
// bouton manuel (compactMessages, sans options), qui redemandent un résumé.
|
||||
if _, changed := compactMessagesNoted(context.Background(), msgs, Caps{}, compactOpts{conv: "c9", used: 7000}); changed {
|
||||
t.Fatal("compaction acceptée")
|
||||
}
|
||||
compactRefused.mu.Lock()
|
||||
_, noted := compactRefused.m["c9"]
|
||||
compactRefused.mu.Unlock()
|
||||
if !noted {
|
||||
t.Fatal("refus non noté")
|
||||
}
|
||||
if err := SetConfigKey("COMPACT_CONTINUATION", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := len(m.all())
|
||||
compactMessages(context.Background(), msgs, Caps{})
|
||||
if len(m.all()) != before+1 {
|
||||
t.Fatal("le chemin réactif ne doit pas tenir compte des refus mémorisés")
|
||||
}
|
||||
// Un cas qui réussit n'est jamais déclaré sans issue.
|
||||
h := contHistory()
|
||||
head, tailStart = compactBounds(h, int(float64(estimateTokens(h))*compactTailFrac))
|
||||
if compactHopeless(h, head, tailStart) {
|
||||
t.Fatal("compaction faisable déclarée sans issue")
|
||||
}
|
||||
}
|
||||
|
||||
// Mémoire des refus : pas de nouvel essai avant +10 % de contexte, jamais à
|
||||
// 90 % de la fenêtre, oubliée quand le contexte baisse ou que l'historique est
|
||||
// réécrit ; rien sans la clé.
|
||||
func TestCompactRefusedSkip(t *testing.T) {
|
||||
testHome(t)
|
||||
contReset(t)
|
||||
if err := SetConfigKey("CTX", "65536"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := contHistory()
|
||||
grown := append(append([]Message(nil), h...), um("suite"))
|
||||
compactRefusedNote("c1", 50000, h)
|
||||
if compactRefusedSkip("c1", 52000, grown) {
|
||||
t.Fatal("sans la clé, aucun essai ne doit être sauté")
|
||||
}
|
||||
if err := SetConfigKey("COMPACT_CONTINUATION", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !compactRefusedSkip("c1", 52000, grown) || compactRefusedSkip("c2", 52000, grown) {
|
||||
t.Fatal("refus mal appliqué")
|
||||
}
|
||||
if compactRefusedSkip("c1", 59000, grown) {
|
||||
t.Fatal("essai sauté à 90 % de la fenêtre")
|
||||
}
|
||||
if compactRefusedSkip("c1", 55000, grown) {
|
||||
t.Fatal("essai sauté après +10 %")
|
||||
}
|
||||
if compactRefusedSkip("c1", 52000, grown) {
|
||||
t.Fatal("refus non oublié après +10 %")
|
||||
}
|
||||
compactRefusedNote("c1", 50000, h)
|
||||
if compactRefusedSkip("c1", 30000, grown) || compactRefusedSkip("c1", 50000, grown) {
|
||||
t.Fatal("refus non oublié quand le contexte baisse")
|
||||
}
|
||||
// Historique réécrit (édition, régénération, discussion vidée puis
|
||||
// regarnie) : même taille de contexte, autre fil — on retente.
|
||||
compactRefusedNote("c1", 50000, h)
|
||||
edited := append([]Message(nil), grown...)
|
||||
edited[3] = um("message modifié")
|
||||
if compactRefusedSkip("c1", 51000, edited) || compactRefusedSkip("c1", 51000, grown) {
|
||||
t.Fatal("refus gardé pour un historique réécrit")
|
||||
}
|
||||
compactRefusedNote("c1", 50000, h)
|
||||
if compactRefusedSkip("c1", 51000, h[:5]) {
|
||||
t.Fatal("refus gardé pour un historique raccourci")
|
||||
}
|
||||
// Une compaction réussie l'efface.
|
||||
compactRefusedNote("c1", 50000, h)
|
||||
compactRefusedClear("c1")
|
||||
if compactRefusedSkip("c1", 51000, grown) {
|
||||
t.Fatal("refus non effacé")
|
||||
}
|
||||
}
|
||||
|
||||
// buildChatPayload : sans noSampling, l'échantillonnage du preset est posé
|
||||
// comme avant ; avec, il ne l'est pas.
|
||||
func TestBuildChatPayloadNoSampling(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := SetConfigKey("TOP_P", "0.9"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ep := chatEndpoint{Model: "m"}
|
||||
if p := buildChatPayload(ep, []Message{um("x")}, 0.7, chatPayloadOpts{}); p["top_p"] != 0.9 {
|
||||
t.Fatalf("échantillonnage perdu : %v", p)
|
||||
}
|
||||
if p := buildChatPayload(ep, []Message{um("x")}, 0.2, chatPayloadOpts{noSampling: true}); p["top_p"] != nil || p["temperature"] != 0.2 {
|
||||
t.Fatalf("échantillonnage posé malgré noSampling : %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Corrections de relecture du lot 2 : une lecture passée par les proxys de
|
||||
// Loki (GET /v1/models, /health d'un client qui sonde) ne calcule rien dans un
|
||||
// slot — elle ne retire pas le tampon de la discussion et n'annule pas un
|
||||
// préchauffage. Une complétion de client, si.
|
||||
func TestProxyReadKeepsSlotStamp(t *testing.T) {
|
||||
testHome(t)
|
||||
contReset(t)
|
||||
if err := SetConfigKey("MODEL", "/models/a.gguf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
end, seq := engineRequestBegin(nil)
|
||||
end()
|
||||
engineMarkMain(seq, "c1")
|
||||
cancelled := false
|
||||
p := &prewarmRun{cancel: func() { cancelled = true }}
|
||||
endP, ok := prewarmBegin(p)
|
||||
if !ok {
|
||||
t.Fatal("préchauffage refusé, moteur libre")
|
||||
}
|
||||
defer endP()
|
||||
// Le préchauffage retire lui-même le tampon : reposé à la main, pour voir
|
||||
// ce que fait la lecture seule.
|
||||
engineGate.mu.Lock()
|
||||
engineGate.main.seq = engineGate.seq
|
||||
engineGate.mu.Unlock()
|
||||
for _, m := range []string{http.MethodGet, http.MethodHead} {
|
||||
r := httptest.NewRequest(m, "/v1/models", nil)
|
||||
engineProxyBegin(r, false)()
|
||||
}
|
||||
if cancelled {
|
||||
t.Error("une lecture a annulé le préchauffage")
|
||||
}
|
||||
if !engineSlotHolds("c1") {
|
||||
t.Error("une lecture a retiré le tampon de la discussion")
|
||||
}
|
||||
engineProxyBegin(httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil), false)()
|
||||
if !cancelled {
|
||||
t.Error("une complétion de client n'a pas annulé le préchauffage")
|
||||
}
|
||||
if engineSlotHolds("c1") {
|
||||
t.Error("tampon gardé après une complétion de client")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Le compactage de secours ne doit se déclencher que sur un vrai débordement de
|
||||
// contexte, pas sur n'importe quel refus du moteur.
|
||||
func TestContextOverflow(t *testing.T) {
|
||||
small := []Message{{Role: "user", Content: "salut"}}
|
||||
yes := []string{
|
||||
`{"error":{"code":400,"message":"request (70000 tokens) exceeds the available context size (65536 tokens), try increasing it","type":"exceed_context_size_error"}}`,
|
||||
`{"error":{"message":"This model's maximum context length is 8192 tokens","code":"context_length_exceeded"}}`,
|
||||
}
|
||||
no := []string{
|
||||
`{"error":{"code":500,"message":"Failed to parse tool call arguments as JSON","type":"server_error"}}`,
|
||||
`{"error":{"code":503,"message":"Loading model","type":"unavailable_error"}}`,
|
||||
`Unable to generate parser for this template`,
|
||||
}
|
||||
for _, m := range yes {
|
||||
if !contextOverflow(m, small) {
|
||||
t.Errorf("débordement non reconnu : %s", m)
|
||||
}
|
||||
}
|
||||
for _, m := range no {
|
||||
if contextOverflow(m, small) {
|
||||
t.Errorf("faux débordement (déclencherait un compactage) : %s", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le dernier recours ramène sous la fenêtre même quand tout est dans un seul
|
||||
// tour (AJEAN 0.17.5).
|
||||
func TestShrinkToFit(t *testing.T) {
|
||||
testHome(t)
|
||||
big := strings.Repeat("x", 200000)
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: "ctx"},
|
||||
{Role: "user", Content: "vieux"},
|
||||
{Role: "assistant", Content: "ok"},
|
||||
{Role: "user", Content: "lis tout"},
|
||||
{Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "a"}}},
|
||||
{Role: "tool", ToolCallID: "a", Content: big},
|
||||
{Role: "tool", ToolCallID: "b", Content: big},
|
||||
}
|
||||
out, changed := shrinkToFit(msgs, 132291)
|
||||
if !changed || out[0].Role != "system" {
|
||||
t.Fatalf("pas réduit : %v", changed)
|
||||
}
|
||||
if est := estimateTokens(out); est > int(float64(ctxWindow())*0.6) {
|
||||
t.Fatalf("encore trop gros : %d", est)
|
||||
}
|
||||
if overflowTokens(`{"message":"request (132291 tokens) exceeds"}`) != 132291 {
|
||||
t.Fatal("taille réelle non lue")
|
||||
}
|
||||
}
|
||||
|
||||
// Un résultat d'outil géant est borné pour le modèle, avec une mention qui dit
|
||||
// au modèle de cibler plus précisément.
|
||||
func TestCapToolResult(t *testing.T) {
|
||||
if got := capToolResult("court"); got != "court" {
|
||||
t.Fatalf("résultat court modifié : %q", got)
|
||||
}
|
||||
got := capToolResult(strings.Repeat("é", toolResultMax+500))
|
||||
if r := []rune(got); len(r) > toolResultMax+200 || !strings.Contains(got, "tronqué") {
|
||||
t.Fatalf("résultat géant mal borné (%d runes)", len(r))
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -20,10 +26,17 @@ func TestCompactBoundsProtectsHead(t *testing.T) {
|
||||
um("q2"), am("r2"),
|
||||
um("q3"), am("r3"),
|
||||
}
|
||||
// budget minuscule → queue = juste le dernier tour, tête = system + 1er user.
|
||||
// budget minuscule → queue = juste le dernier tour, tête = les seuls systèmes.
|
||||
// Le 1er message user N'EST PLUS épinglé : il l'était pour « ancrer
|
||||
// l'objectif », mais après compaction le modèle voyait deux demandes (celle du
|
||||
// début, épinglée, et la courante réinjectée par `pending`) et répondait
|
||||
// volontiers à l'ancienne. Voir compactBounds et TestCompactKeepsPendingRequest.
|
||||
head, tail := compactBounds(msgs, 1)
|
||||
if head != 2 { // system + premier user
|
||||
t.Fatalf("head = %d, attendu 2", head)
|
||||
if head != 1 { // le message système, et lui seul
|
||||
t.Fatalf("head = %d, attendu 1 (systèmes uniquement)", head)
|
||||
}
|
||||
if msgs[head].Role != "user" {
|
||||
t.Fatalf("le 1er message user doit être compactable, il commence le torse ; rôle obtenu %q", msgs[head].Role)
|
||||
}
|
||||
// Frontière sûre = user ou assistant (jamais un `tool`, qui serait orphelin).
|
||||
if r := msgs[tail].Role; r != "user" && r != "assistant" {
|
||||
@@ -170,3 +183,149 @@ func TestEstimateTokensGrows(t *testing.T) {
|
||||
t.Fatalf("estimateTokens ne croît pas avec la taille: small=%d big=%d", small, big)
|
||||
}
|
||||
}
|
||||
|
||||
// Repli sans résumé (moteur absent) : la demande en cours est déjà dans le
|
||||
// torse dégraissé, elle ne doit pas être réinjectée une seconde fois — elle
|
||||
// apparaissait APRÈS ses propres résultats d'outils (AJEAN 0.17.4).
|
||||
func TestCompactSansResumeNeDoublePasLaDemande(t *testing.T) {
|
||||
testHome(t)
|
||||
page := func(n int) Message {
|
||||
return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
|
||||
}
|
||||
msgs := []Message{
|
||||
um("première question de la conversation"), am("ok"),
|
||||
um("cherche les horaires du train pour Lyon"),
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
msgs = append(msgs, atc("web_read"), page(i))
|
||||
}
|
||||
out, _ := compactMessages(t.Context(), msgs, Caps{})
|
||||
n := 0
|
||||
for _, m := range out {
|
||||
if m.Role == "user" && strings.Contains(msgText(m), "horaires du train") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("demande en cours présente %d fois après compactage, attendu 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Mode Code : le résumé demande les fichiers touchés et les erreurs, et l'état
|
||||
// des critères est rendu au builder après compactage.
|
||||
func TestCompactageModeCodeGardeLEtat(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
toolCriteria(map[string]any{"action": "add", "texts": []any{"les tests passent"}}, false)
|
||||
var sys string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
sys = msgText(body.Messages[0])
|
||||
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- main.go modifié : la fonction parse corrigée, go build et go test passent"}}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := func(n int) Message {
|
||||
return tm("sortie de commande " + string(rune('a'+n)) + strings.Repeat("x", 400))
|
||||
}
|
||||
msgs := []Message{um("corrige le build"), am("ok"), um("continue")}
|
||||
for i := 0; i < 10; i++ {
|
||||
msgs = append(msgs, atc("bash"), page(i))
|
||||
}
|
||||
out, changed := compactMessages(t.Context(), msgs, Caps{Agent: true, Code: true})
|
||||
if !changed {
|
||||
t.Fatal("pas compacté")
|
||||
}
|
||||
if !strings.Contains(sys, "CODING session") {
|
||||
t.Fatal("consigne de résumé du mode Code absente")
|
||||
}
|
||||
found := false
|
||||
for _, m := range out {
|
||||
if strings.Contains(msgText(m), "Acceptance criteria (current state)") && strings.Contains(msgText(m), "les tests passent") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("critères non rendus après compactage")
|
||||
}
|
||||
}
|
||||
|
||||
// Le résumé n'est plus coupé à 2200 caractères : max_tokens borne déjà la
|
||||
// sortie, et l'ancienne coupe faisait tomber l'ÉTAT D'AVANCEMENT, écrit en
|
||||
// dernier. Seul le raisonnement sans résumé est refusé (repli sur le torse).
|
||||
func TestCleanSummary(t *testing.T) {
|
||||
testHome(t)
|
||||
// Fenêtre fixée : le filet (budget×6) en dépend, le cas « long » ne doit pas
|
||||
// basculer au-delà si la valeur par défaut de CTX change.
|
||||
if err := SetConfigKey("CTX", "65536"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
long := strings.Repeat("é", 7000) + " ÉTAT D'AVANCEMENT"
|
||||
huge := strings.Repeat("x", summaryRuneCap()+500)
|
||||
cases := []struct {
|
||||
name, content, reasoning, finish string
|
||||
want string
|
||||
err bool
|
||||
}{
|
||||
{"simple", " - fait A\n- fait B ", "", "stop", "- fait A\n- fait B", false},
|
||||
{"long gardé entier", long, "", "stop", long, false},
|
||||
{"think retiré", "<think>je réfléchis</think>\n- résumé", "", "stop", "- résumé", false},
|
||||
{"length gardé et marqué", "- début du résumé", "", "length", "- début du résumé […]", false},
|
||||
{"au-delà du filet", huge, "", "stop", strings.Repeat("x", summaryRuneCap()) + " […]", false},
|
||||
{"balise citée en milieu", "- le parseur retire <think> en tête", "", "stop", "- le parseur retire <think> en tête", false},
|
||||
{"bloc cité en milieu gardé entier", "- A\n- le parseur retire <think>x</think> en tête\n- B", "", "stop", "- A\n- le parseur retire <think>x</think> en tête\n- B", false},
|
||||
{"think retiré, balise citée après", "<think>r</think>\n- A\n- le parseur coupe à </think>\n- B", "", "stop", "- A\n- le parseur coupe à </think>\n- B", false},
|
||||
{"ouverture dans le prompt", "je réfléchis</think>\n- résumé", "", "stop", "- résumé", false},
|
||||
{"deux blocs en tête", "<think>a</think><think>b</think>\n- résumé", "", "stop", "- résumé", false},
|
||||
{"second bloc jamais refermé", "<think>a</think>\n<think>b", "", "length", "", true},
|
||||
{"think jamais refermé", "<think>je réfléchis encore", "", "length", "", true},
|
||||
{"que du reasoning_content", "", "je réfléchis", "length", "", true},
|
||||
{"think vide de réponse", "<think>x</think> ", "", "stop", "", true},
|
||||
{"vide", "", "", "stop", "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := cleanSummary(c.content, c.reasoning, c.finish)
|
||||
if (err != nil) != c.err || got != c.want {
|
||||
t.Errorf("%s : %q, %v", c.name, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bout à bout : un résumé de 7000 caractères revient intact du moteur, et une
|
||||
// réponse qui n'a que du raisonnement est une erreur (repli de l'appelant).
|
||||
func TestSummarizeLongNotTruncated(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := SetConfigKey("CTX", "65536"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
long := strings.Repeat("ü", 7000)
|
||||
// Réponse changée entre deux requêtes : derrière un mutex, le handler tourne
|
||||
// dans une goroutine du serveur.
|
||||
var mu sync.Mutex
|
||||
var reply map[string]any
|
||||
setReply := func(r map[string]any) { mu.Lock(); reply = r; mu.Unlock() }
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(reply)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setReply(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": long}, "finish_reason": "stop"}}})
|
||||
got, err := summarizeTranscriptFor(context.Background(), "transcript", false)
|
||||
if err != nil || got != long {
|
||||
t.Fatalf("résumé tronqué : %d caractères, %v", len([]rune(got)), err)
|
||||
}
|
||||
setReply(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "", "reasoning_content": "hmm"}, "finish_reason": "length"}}})
|
||||
if got, err := summarizeTranscriptFor(context.Background(), "transcript", false); err == nil {
|
||||
t.Fatalf("raisonnement seul accepté comme résumé : %q", got)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,155 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// resetConvForTest vide l'état en RAM pour forcer un VRAI rechargement depuis la
|
||||
// base, comme au démarrage d'un process qui ne connaît encore rien. `conv` est un
|
||||
// global du paquet : sans ça, un test verrait l'état laissé par le précédent.
|
||||
func resetConvForTest() {
|
||||
conv.mu.Lock()
|
||||
conv.Messages, conv.Log, conv.Seq, conv.CtxUsed = nil, nil, 0, 0
|
||||
conv.mu.Unlock()
|
||||
}
|
||||
|
||||
// TestLoadConversationReessayeSousContention : la panne d'origine est un verrou
|
||||
// bbolt transitoire au redémarrage du conteneur (l'ancien process tient encore
|
||||
// le fichier pendant que le nouveau démarre), confondu avec « rien à charger ».
|
||||
//
|
||||
// Réserve honnête : ce test ne reproduit PAS la panne à coup sûr. withDB ouvre
|
||||
// déjà la base avec Options.Timeout de 5 s, qui absorbe seul une contention de
|
||||
// 400 ms — ce test passe donc aussi sur le code d'AVANT le correctif. Il est
|
||||
// gardé comme filet de non-régression sur le nouveau chemin (getBytesErr + boucle
|
||||
// de tentatives) : ce chemin ne doit ni perdre la conversation, ni se bloquer.
|
||||
func TestLoadConversationReessayeSousContention(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
// 1. Écrit une conversation par l'API normale (elle crée aussi la clé active).
|
||||
resetConvForTest()
|
||||
conv.mu.Lock()
|
||||
conv.Messages = []Message{{Role: "user", Content: "bonjour"}}
|
||||
conv.mu.Unlock()
|
||||
conv.persist()
|
||||
activeID := getStr(bkChat, ckActive)
|
||||
if activeID == "" {
|
||||
t.Fatal("persist n'a pas créé de discussion active — le montage du test est faux")
|
||||
}
|
||||
|
||||
// 2. État en RAM vidé : le rechargement doit tout reprendre depuis la base.
|
||||
resetConvForTest()
|
||||
|
||||
// 3. Verrou exclusif tenu une fenêtre courte dans une goroutine séparée : la
|
||||
// base est momentanément indisponible, exactement comme au chevauchement.
|
||||
db, err := bolt.Open(dbPath(), 0o600, &bolt.Options{Timeout: 2 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("impossible de prendre le verrou pour le test : %v", err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
db.Close()
|
||||
}()
|
||||
|
||||
LoadConversation()
|
||||
wg.Wait()
|
||||
|
||||
conv.mu.Lock()
|
||||
got := len(conv.Messages)
|
||||
conv.mu.Unlock()
|
||||
if got != 1 {
|
||||
t.Fatalf("conversation perdue malgré une contention temporaire : %d message(s), attendu 1", got)
|
||||
}
|
||||
if id := getStr(bkChat, ckActive); id != activeID {
|
||||
t.Fatalf("la discussion active a changé (%q → %q) : le fil d'origine est orphelin", activeID, id)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConversationBaseIllisibleNeForgePasDeDiscussion garde l'invariant qui
|
||||
// justifie le correctif : convEnsureActive ÉCRIT (elle forge un identifiant et
|
||||
// l'enregistre sous bkChat/active). Elle ne doit donc jamais être appelée sur la
|
||||
// foi d'une lecture ratée — sinon un verrou durable suffisait à mettre le fil en
|
||||
// cours de côté au profit d'une discussion neuve, en silence.
|
||||
//
|
||||
// Réserve honnête, là encore : avec une base illisible l'écriture échoue elle
|
||||
// aussi, donc le disque finit identique dans les deux versions. Ce que ce test
|
||||
// verrouille, c'est le CONTRAT — sortir sans rien écrire, et sans faire attendre
|
||||
// indéfiniment — pas la reproduction du dégât.
|
||||
func TestLoadConversationBaseIllisibleNeForgePasDeDiscussion(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
// Fichier de base invalide : bolt.Open échoue tout de suite (pas d'attente de
|
||||
// verrou), ce qui exerce le chemin d'erreur sans allonger le test.
|
||||
path := filepath.Join(home, "loki.db")
|
||||
if err := os.WriteFile(path, []byte("ceci n'est pas une base bbolt"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resetConvForTest()
|
||||
start := time.Now()
|
||||
LoadConversation()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Budget : loadConvAttempts tentatives espacées de loadConvRetryWait, plus une
|
||||
// marge. Au-delà, la boucle de reprise retarderait le démarrage du serveur.
|
||||
if max := time.Duration(loadConvAttempts)*loadConvRetryWait + 2*time.Second; elapsed > max {
|
||||
t.Fatalf("démarrage retardé de %v par une base illisible (budget %v)", elapsed, max)
|
||||
}
|
||||
// Contrepartie de l'assertion « aucune reprise » du test d'absence légitime :
|
||||
// un compteur bloqué à zéro la rendrait vraie sans rien prouver. Ici les
|
||||
// reprises ont bel et bien lieu, et on le vérifie.
|
||||
if n := loadConvRetries.Load(); int(n) != loadConvAttempts {
|
||||
t.Fatalf("%d reprise(s) comptée(s) sur une base illisible, attendu %d", n, loadConvAttempts)
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("la base a été modifiée alors qu'elle n'a même pas pu être lue")
|
||||
}
|
||||
conv.mu.Lock()
|
||||
n := len(conv.Messages)
|
||||
conv.mu.Unlock()
|
||||
if n != 0 {
|
||||
t.Fatalf("%d message(s) chargés depuis une base illisible", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConversationAbsenceLegitimeResteRapide : le correctif ne doit pas avoir
|
||||
// transformé le cas NORMAL (base saine, rien d'enregistré encore — premier
|
||||
// démarrage) en attente inutile. Une seule tentative doit suffire.
|
||||
func TestLoadConversationAbsenceLegitimeResteRapide(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
resetConvForTest()
|
||||
|
||||
LoadConversation()
|
||||
|
||||
// Mesuré sur le COMPTEUR de reprises, pas au chronomètre : une absence
|
||||
// légitime doit sortir au premier essai. Le temps de mur ne disait pas ça —
|
||||
// il mêlait le coût de l'essai lui-même (création de la base bbolt, fsync)
|
||||
// à l'attente de reprise, et un runner CI chargé l'a fait échouer à 435 ms
|
||||
// alors qu'aucune reprise n'avait été déclenchée.
|
||||
if n := loadConvRetries.Load(); n != 0 {
|
||||
t.Fatalf("absence légitime traitée comme une erreur : %d reprise(s) déclenchée(s), aucune ne devait l'être", n)
|
||||
}
|
||||
conv.mu.Lock()
|
||||
n := len(conv.Messages)
|
||||
conv.mu.Unlock()
|
||||
if n != 0 {
|
||||
t.Fatalf("%d message(s) chargés alors que rien n'avait été enregistré", n)
|
||||
}
|
||||
}
|
||||
@@ -225,3 +225,87 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) {
|
||||
c.appendDelta(c.epoch, map[string]any{"content": "suite"})
|
||||
waitSeq(t, got, 503)
|
||||
}
|
||||
|
||||
// --- Régressions v0.12.7 : journal d'affichage (outils, replay, changement de session) ---
|
||||
|
||||
// compactLogLocked doit coalescer les événements tool_used d'un même appel en un
|
||||
// seul (le done=true, qui porte l'état final) : les intermédiaires (annonce, frappe
|
||||
// du corps) ne servent qu'à l'affichage en direct et faisaient enfler le journal
|
||||
// jusqu'à la troncature des premiers messages.
|
||||
func TestCompactLogCoalesceTools(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.Log = []LogEvent{
|
||||
{Seq: 1, Delta: map[string]any{"user": "édite"}},
|
||||
{Seq: 2, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f"}}},
|
||||
{Seq: 3, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f", "typing": true, "body": "a"}}},
|
||||
{Seq: 4, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f", "result": "ok", "done": true}}},
|
||||
{Seq: 5, Delta: map[string]any{"content": "fini"}},
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.compactLogLocked()
|
||||
c.mu.Unlock()
|
||||
nTool := 0
|
||||
for _, ev := range c.Log {
|
||||
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
|
||||
nTool++
|
||||
if done, _ := tu["done"].(bool); !done {
|
||||
t.Error("un tool_used non terminé a survécu à la compaction")
|
||||
}
|
||||
}
|
||||
}
|
||||
if nTool != 1 {
|
||||
t.Fatalf("compactLogLocked garde %d tool_used au lieu de 1", nTool)
|
||||
}
|
||||
}
|
||||
|
||||
// coalesceReplay ne doit émettre un outil qu'UNE fois même si un événement non-outil
|
||||
// (stats, un bout de raisonnement) tombe entre l'annonce (done=false) et le done=true.
|
||||
func TestCoalesceReplayNoDoubleTool(t *testing.T) {
|
||||
events := []LogEvent{
|
||||
{Seq: 1, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls"}}},
|
||||
{Seq: 2, Delta: map[string]any{"stats": map[string]any{"gen_tokens": 3}}},
|
||||
{Seq: 3, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls", "result": "x", "done": true}}},
|
||||
}
|
||||
n := 0
|
||||
for _, ev := range coalesceReplay(events, 0) {
|
||||
if _, ok := ev["tool_used"]; ok {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("outil émis %d fois au replay (attendu 1)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Un abonné qui arrive avec un `from` SUPÉRIEUR au dernier Seq du journal (curseur
|
||||
// hérité d'une autre session, plus récente) doit obtenir le fil COMPLET, pas rien :
|
||||
// sans ce garde-fou, ouvrir une session plus ancienne la montrait tronquée/vide.
|
||||
func TestSubscribeStaleFromCrossSession(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.appendDelta(c.epoch, map[string]any{"user": "premier message"})
|
||||
c.appendDelta(c.epoch, map[string]any{"content": "réponse"})
|
||||
// Le journal va jusqu'à Seq 2 ; on s'abonne avec from=9999 (curseur d'une autre session).
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var got []map[string]any
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
c.Subscribe(ctx, 9999, func(ev map[string]any) bool {
|
||||
got = append(got, ev)
|
||||
if ev["caught_up"] != nil {
|
||||
cancel()
|
||||
}
|
||||
return true
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
<-done
|
||||
seen := ""
|
||||
for _, ev := range got {
|
||||
if u, ok := ev["user"].(string); ok {
|
||||
seen += u
|
||||
}
|
||||
}
|
||||
if !strings.Contains(seen, "premier message") {
|
||||
t.Fatalf("le premier message n'a pas été rejoué avec un from périmé (reçu: %q)", seen)
|
||||
}
|
||||
}
|
||||
@@ -278,7 +278,7 @@ func migrateLegacyUploads(src string) {
|
||||
// d'une discussion (delta `files`, cf. StartTurn). C'est la seule trace qui
|
||||
// relie un fichier déposé à sa discussion.
|
||||
func convAttachNames(id string) []string {
|
||||
b := getBytes(bkChat, convKey(id))
|
||||
b := storeBytes(bkChat, convKey(id))
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
+83
-24
@@ -5,12 +5,18 @@ package loki
|
||||
// envoie à l'UI le détail des lignes ajoutées et retirées, qu'elle affiche en
|
||||
// vert (+) et rouge (-). Aucune dépendance : LCS classique sur les lignes, avec
|
||||
// des garde-fous pour ne jamais transformer un gros remplacement en pavé.
|
||||
//
|
||||
// Les lignes envoyées sont bornées (diffMaxShown), mais les compteurs +N / -N
|
||||
// sont calculés AVANT la coupe et renvoyés à part : l'UI les comptait dans le
|
||||
// diff tronqué, d'où un « +500 » pendant l'écriture qui retombait à « +120 ».
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
diffMaxLines = 400 // au-delà, on compare sans détail (trop gros / trop lent)
|
||||
diffMaxShown = 120 // lignes envoyées à l'UI (le reste est résumé)
|
||||
diffMaxLines = 400 // au-delà (partie modifiée), on compare sans détail (LCS trop lente)
|
||||
diffMaxShown = 120 // lignes envoyées à l'UI (le reste est résumé)
|
||||
diffContext = 3 // lignes identiques gardées autour d'un changement
|
||||
diffOmitPrefix = "…"
|
||||
)
|
||||
|
||||
// DiffLine est une ligne de diff : Op vaut " " (contexte), "-" ou "+".
|
||||
@@ -19,26 +25,69 @@ type DiffLine struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// lineDiff compare deux blocs de texte ligne à ligne. Le résultat garde les
|
||||
// lignes communes comme contexte : c'est ce qui rend le changement lisible
|
||||
// quand l'IA ne touche qu'un mot au milieu d'un paragraphe.
|
||||
func lineDiff(oldText, newText string) []DiffLine {
|
||||
// lineDiff compare deux blocs de texte ligne à ligne et renvoie les lignes à
|
||||
// afficher (bornées) avec le nombre RÉEL de lignes ajoutées et retirées.
|
||||
//
|
||||
// Les lignes communes en tête et en queue sont écartées avant la LCS : un
|
||||
// changement d'une ligne dans un gros bloc reste un +1 -1 lisible (au lieu d'un
|
||||
// « tout retiré, tout ajouté ») et le changement n'est pas repoussé hors des
|
||||
// lignes affichées par des centaines de lignes de contexte.
|
||||
func lineDiff(oldText, newText string) (lines []DiffLine, added, removed int) {
|
||||
a := splitLines(oldText)
|
||||
b := splitLines(newText)
|
||||
// Blocs énormes : on ne calcule pas la LCS (coût quadratique), on montre
|
||||
// simplement l'ancien en retrait et le nouveau en ajout.
|
||||
if len(a) > diffMaxLines || len(b) > diffMaxLines {
|
||||
out := make([]DiffLine, 0, len(a)+len(b))
|
||||
for _, l := range a {
|
||||
out = append(out, DiffLine{Op: "-", Text: l})
|
||||
pre := 0
|
||||
for pre < len(a) && pre < len(b) && a[pre] == b[pre] {
|
||||
pre++
|
||||
}
|
||||
suf := 0
|
||||
for suf < len(a)-pre && suf < len(b)-pre && a[len(a)-1-suf] == b[len(b)-1-suf] {
|
||||
suf++
|
||||
}
|
||||
ma, mb := a[pre:len(a)-suf], b[pre:len(b)-suf]
|
||||
|
||||
var mid []DiffLine
|
||||
if len(ma) > diffMaxLines || len(mb) > diffMaxLines {
|
||||
// Partie modifiée énorme : pas de LCS (coût quadratique), l'ancien en
|
||||
// retrait puis le nouveau en ajout.
|
||||
mid = make([]DiffLine, 0, len(ma)+len(mb))
|
||||
for _, l := range ma {
|
||||
mid = append(mid, DiffLine{Op: "-", Text: l})
|
||||
}
|
||||
for _, l := range b {
|
||||
out = append(out, DiffLine{Op: "+", Text: l})
|
||||
for _, l := range mb {
|
||||
mid = append(mid, DiffLine{Op: "+", Text: l})
|
||||
}
|
||||
} else {
|
||||
mid = lcsDiff(ma, mb)
|
||||
}
|
||||
for _, d := range mid {
|
||||
switch d.Op {
|
||||
case "+":
|
||||
added++
|
||||
case "-":
|
||||
removed++
|
||||
}
|
||||
return capLines(out)
|
||||
}
|
||||
|
||||
// LCS : table des longueurs, puis remontée.
|
||||
// Contexte autour du changement : quelques lignes, le reste résumé.
|
||||
var out []DiffLine
|
||||
if pre > diffContext {
|
||||
out = append(out, DiffLine{Op: " ", Text: omitted(pre - diffContext)})
|
||||
}
|
||||
for _, l := range a[max(0, pre-diffContext):pre] {
|
||||
out = append(out, DiffLine{Op: " ", Text: l})
|
||||
}
|
||||
out = append(out, mid...)
|
||||
for _, l := range a[len(a)-suf : len(a)-suf+min(suf, diffContext)] {
|
||||
out = append(out, DiffLine{Op: " ", Text: l})
|
||||
}
|
||||
if suf > diffContext {
|
||||
out = append(out, DiffLine{Op: " ", Text: omitted(suf - diffContext)})
|
||||
}
|
||||
return capLines(out), added, removed
|
||||
}
|
||||
|
||||
// lcsDiff : diff exact par plus longue sous-séquence commune.
|
||||
func lcsDiff(a, b []string) []DiffLine {
|
||||
n, m := len(a), len(b)
|
||||
lcs := make([][]int, n+1)
|
||||
for i := range lcs {
|
||||
@@ -77,27 +126,37 @@ func lineDiff(oldText, newText string) []DiffLine {
|
||||
for ; j < m; j++ {
|
||||
out = append(out, DiffLine{Op: "+", Text: b[j]})
|
||||
}
|
||||
return capLines(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// addedDiff présente un contenu entièrement nouveau (création d'une page).
|
||||
func addedDiff(text string) []DiffLine {
|
||||
lines := splitLines(text)
|
||||
out := make([]DiffLine, 0, len(lines))
|
||||
for _, l := range lines {
|
||||
// addedDiff présente un contenu entièrement nouveau (création d'une page ou
|
||||
// d'un fichier) : toutes ses lignes en ajout.
|
||||
func addedDiff(text string) (lines []DiffLine, added int) {
|
||||
ls := splitLines(text)
|
||||
out := make([]DiffLine, 0, len(ls))
|
||||
for _, l := range ls {
|
||||
out = append(out, DiffLine{Op: "+", Text: l})
|
||||
}
|
||||
return capLines(out)
|
||||
return capLines(out), len(ls)
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
// Un saut de ligne final termine la dernière ligne, il n'en ouvre pas une vide.
|
||||
s = strings.TrimSuffix(s, "\n")
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
|
||||
func omitted(n int) string {
|
||||
if n == 1 {
|
||||
return diffOmitPrefix + "(1 ligne identique)"
|
||||
}
|
||||
return diffOmitPrefix + "(" + itoa(n) + " lignes identiques)"
|
||||
}
|
||||
|
||||
// capLines borne la taille envoyée à l'UI et signale ce qui a été coupé.
|
||||
func capLines(d []DiffLine) []DiffLine {
|
||||
if len(d) <= diffMaxShown {
|
||||
@@ -105,7 +164,7 @@ func capLines(d []DiffLine) []DiffLine {
|
||||
}
|
||||
cut := len(d) - diffMaxShown
|
||||
out := append([]DiffLine{}, d[:diffMaxShown]...)
|
||||
return append(out, DiffLine{Op: " ", Text: "…(" + itoa(cut) + " lignes de plus)"})
|
||||
return append(out, DiffLine{Op: " ", Text: diffOmitPrefix + "(" + itoa(cut) + " lignes de plus)"})
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func numbered(n int, suffix string) string {
|
||||
var b strings.Builder
|
||||
for i := 1; i <= n; i++ {
|
||||
b.WriteString("ligne " + itoa(i) + suffix + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Un fichier de 500 lignes : +500, même si l'UI n'en reçoit que 120.
|
||||
func TestAddedDiffCountsBeyondCap(t *testing.T) {
|
||||
lines, added := addedDiff(numbered(500, ""))
|
||||
if added != 500 {
|
||||
t.Fatalf("added = %d, attendu 500", added)
|
||||
}
|
||||
if len(lines) > diffMaxShown+1 {
|
||||
t.Fatalf("%d lignes envoyées, plafond %d", len(lines), diffMaxShown)
|
||||
}
|
||||
}
|
||||
|
||||
// Le saut de ligne final ne compte pas pour une ligne.
|
||||
func TestAddedDiffTrailingNewline(t *testing.T) {
|
||||
if _, n := addedDiff("a\nb\n"); n != 2 {
|
||||
t.Fatalf("n = %d, attendu 2", n)
|
||||
}
|
||||
if _, n := addedDiff("a\nb"); n != 2 {
|
||||
t.Fatalf("n = %d, attendu 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Une ligne changée au milieu d'un gros bloc : +1 -1, et le changement est
|
||||
// bien dans les lignes affichées (pas noyé sous le contexte).
|
||||
func TestLineDiffSmallChangeInBigBlock(t *testing.T) {
|
||||
old := numbered(800, "")
|
||||
neu := strings.Replace(old, "ligne 600\n", "ligne 600 modifiée\n", 1)
|
||||
lines, add, del := lineDiff(old, neu)
|
||||
if add != 1 || del != 1 {
|
||||
t.Fatalf("+%d -%d, attendu +1 -1", add, del)
|
||||
}
|
||||
var sawAdd, sawDel bool
|
||||
for _, l := range lines {
|
||||
sawAdd = sawAdd || (l.Op == "+" && l.Text == "ligne 600 modifiée")
|
||||
sawDel = sawDel || (l.Op == "-" && l.Text == "ligne 600")
|
||||
}
|
||||
if !sawAdd || !sawDel {
|
||||
t.Fatalf("le changement n'est pas dans le diff affiché : %v", lines)
|
||||
}
|
||||
if len(lines) > 2*diffContext+4 {
|
||||
t.Fatalf("trop de contexte : %d lignes", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineDiffCountsAreExact(t *testing.T) {
|
||||
_, add, del := lineDiff("a\nb\nc\n", "a\nx\ny\nc\n")
|
||||
if add != 2 || del != 1 {
|
||||
t.Fatalf("+%d -%d, attendu +2 -1", add, del)
|
||||
}
|
||||
_, add, del = lineDiff(numbered(300, ""), numbered(300, " bis"))
|
||||
if add != 300 || del != 300 {
|
||||
t.Fatalf("+%d -%d, attendu +300 -300", add, del)
|
||||
}
|
||||
}
|
||||
@@ -103,12 +103,80 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
|
||||
Version: Version,
|
||||
ExportedAt: time.Now().Format(time.RFC3339),
|
||||
CtxUsed: ctxUsed,
|
||||
Messages: msgs,
|
||||
Log: filterLog(log, o),
|
||||
// Sans les mises à jour du projet (PROJ_SNAPSHOT) : l'export rend la
|
||||
// conversation, pas les données que Loki y a glissées.
|
||||
Messages: stripInlineImages(stripContextUpdates(msgs)),
|
||||
Log: filterLog(log, o),
|
||||
}
|
||||
return json.MarshalIndent(p, "", " ")
|
||||
}
|
||||
|
||||
// stripInlineImages remplace les images en base64 (data URI) portées par les
|
||||
// messages multimodaux (vision : pièces jointes images + outil see_image) par un
|
||||
// court marqueur. Sans ça, l'export JSON d'une conversation avec images embarquait
|
||||
// plusieurs Mo de base64 par image — un fichier énorme et illisible. Le Markdown,
|
||||
// lui, n'était pas concerné : il ne cite que les NOMS de fichiers. Le journal
|
||||
// d'affichage non plus (il ne porte pas les images). On ne touche donc qu'ici, et
|
||||
// sur une COPIE : les messages appartiennent à la conversation vivante.
|
||||
func stripInlineImages(msgs []Message) []Message {
|
||||
out := make([]Message, len(msgs))
|
||||
for i, m := range msgs {
|
||||
out[i] = m
|
||||
out[i].Content = stripInlineImagesValue(m.Content)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stripInlineImagesValue(v any) any {
|
||||
// Une partie image (format OpenAI) : {"type":"image_url","image_url":{"url":"data:…"}}.
|
||||
// On copie la partie et on élide l'URL data: en gardant l'en-tête (type MIME) lisible.
|
||||
elide := func(part map[string]any) map[string]any {
|
||||
cp := make(map[string]any, len(part))
|
||||
for k, val := range part {
|
||||
cp[k] = val
|
||||
}
|
||||
if iu, ok := cp["image_url"].(map[string]any); ok {
|
||||
c2 := make(map[string]any, len(iu))
|
||||
for k, val := range iu {
|
||||
c2[k] = val
|
||||
}
|
||||
if url, ok := c2["url"].(string); ok && strings.HasPrefix(url, "data:") {
|
||||
c2["url"] = elideDataURI(url)
|
||||
}
|
||||
cp["image_url"] = c2
|
||||
}
|
||||
return cp
|
||||
}
|
||||
switch parts := v.(type) {
|
||||
case []map[string]any: // message fraîchement produit
|
||||
out := make([]map[string]any, len(parts))
|
||||
for i, p := range parts {
|
||||
out[i] = elide(p)
|
||||
}
|
||||
return out
|
||||
case []any: // message relu depuis la base (JSON) : []any de maps
|
||||
out := make([]any, len(parts))
|
||||
for i, p := range parts {
|
||||
if mp, ok := p.(map[string]any); ok {
|
||||
out[i] = elide(mp)
|
||||
} else {
|
||||
out[i] = p
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return v // simple chaîne (cas courant) : rien à faire
|
||||
}
|
||||
|
||||
// elideDataURI garde l'en-tête « data:<mime>;base64, » (lisible) et remplace le
|
||||
// corps base64 par sa taille, pour que l'export reste informatif sans le poids.
|
||||
func elideDataURI(url string) string {
|
||||
if i := strings.Index(url, ","); i >= 0 {
|
||||
return url[:i+1] + fmt.Sprintf("<élidé — %s>", humanBytes(int64(len(url)-i-1)))
|
||||
}
|
||||
return "<image élidée>"
|
||||
}
|
||||
|
||||
// filterLog applique au journal les MÊMES cases que le Markdown. Les deux
|
||||
// formats avaient auparavant des options distinctes (le JSON proposait un
|
||||
// obscur « journal d'affichage » à la place des trois autres) : personne ne
|
||||
@@ -155,7 +223,8 @@ func filterLog(log []LogEvent, o exportOpts) []LogEvent {
|
||||
func lastTurnsMessages(msgs []Message, n int) []Message {
|
||||
seen := 0
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
if msgs[i].Role != "user" {
|
||||
// Un rappel de loki n'ouvre pas un échange.
|
||||
if msgs[i].Role != "user" || isLokiInjected(msgs[i]) {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
|
||||
@@ -258,3 +258,35 @@ func TestCmdExportEcritLeFichier(t *testing.T) {
|
||||
t.Fatal("option inconnue acceptée")
|
||||
}
|
||||
}
|
||||
|
||||
// L'export JSON d'un message multimodal (vision) doit ÉLIDER le base64 des images :
|
||||
// sinon le fichier pesait plusieurs Mo par image. Le Markdown, lui, n'a jamais porté
|
||||
// les images (juste les noms de fichiers).
|
||||
func TestExportJSONElideImages(t *testing.T) {
|
||||
t.Cleanup(func() { conv.Reset() })
|
||||
big := strings.Repeat("A", 300000)
|
||||
conv.mu.Lock()
|
||||
conv.Messages = []Message{
|
||||
{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "regarde"},
|
||||
{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64," + big}},
|
||||
}},
|
||||
{Role: "assistant", Content: "vu"},
|
||||
}
|
||||
conv.Log = []LogEvent{{Seq: 1, TS: 1, Delta: map[string]any{"user": "regarde"}}}
|
||||
conv.Seq = 1
|
||||
conv.mu.Unlock()
|
||||
b, err := conv.ExportJSON(defaultExportOpts())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(b), big) {
|
||||
t.Error("le base64 brut d'une image est encore présent dans le JSON")
|
||||
}
|
||||
if len(b) > 10000 {
|
||||
t.Errorf("JSON toujours énorme malgré l'élision : %d octets", len(b))
|
||||
}
|
||||
if !strings.Contains(string(b), "élidé") {
|
||||
t.Error("marqueur d'élision absent du JSON")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// historyCut ne garde que les N derniers échanges (un échange commence à `user`).
|
||||
func TestHistoryCut(t *testing.T) {
|
||||
var log []LogEvent
|
||||
seq := 0
|
||||
add := func(d map[string]any) { seq++; log = append(log, LogEvent{Seq: seq, Delta: d}) }
|
||||
for i := 0; i < 5; i++ {
|
||||
add(map[string]any{"user": "q"})
|
||||
add(map[string]any{"content": "r"})
|
||||
add(map[string]any{"turn_done": true})
|
||||
}
|
||||
// 5 échanges, seq 1..15 ; les 2 derniers commencent aux seq 10 et 13.
|
||||
if cut, hidden := historyCut(log, 2); cut != 9 || hidden != 3 {
|
||||
t.Fatalf("tail=2 : cut=%d hidden=%d, attendu 9 et 3", cut, hidden)
|
||||
}
|
||||
if cut, hidden := historyCut(log, 5); cut != 0 || hidden != 0 {
|
||||
t.Fatalf("tail=5 (tout tient) : cut=%d hidden=%d, attendu 0 et 0", cut, hidden)
|
||||
}
|
||||
if cut, hidden := historyCut(log, 0); cut != 0 || hidden != 0 {
|
||||
t.Fatalf("tail=0 : pas de coupure attendue (cut=%d hidden=%d)", cut, hidden)
|
||||
}
|
||||
// Les événements rejoués après la coupure commencent bien au 4e échange.
|
||||
evs := coalesceReplay(log, 9)
|
||||
if u, _ := evs[0]["user"].(string); u != "q" || evs[0]["seq"] != 10 {
|
||||
t.Fatalf("premier événement rejoué inattendu : %v", evs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// La coupe réémet le mode (Chat/Code) posé dans la partie masquée.
|
||||
func TestHistoryHeadCarriesMode(t *testing.T) {
|
||||
log := []LogEvent{
|
||||
{Seq: 1, Delta: map[string]any{"mode": "code"}},
|
||||
{Seq: 2, Delta: map[string]any{"user": "q1"}},
|
||||
{Seq: 3, Delta: map[string]any{"user": "q2"}},
|
||||
}
|
||||
head := historyHead(log, 2, 1)
|
||||
if len(head) != 2 || head[0]["history_more"] != 1 || head[1]["mode"] != "code" {
|
||||
t.Fatalf("en-tête inattendu : %v", head)
|
||||
}
|
||||
}
|
||||
|
||||
// Un client paginé ne reçoit au chargement que les derniers échanges, précédés
|
||||
// de {history_more} ; un ancien client (tail < 0) reçoit tout.
|
||||
func TestSubscribeTailPaginates(t *testing.T) {
|
||||
c := newTestConv()
|
||||
for i := 0; i < 5; i++ {
|
||||
c.appendDelta(c.epoch, map[string]any{"user": "q"})
|
||||
c.appendDelta(c.epoch, map[string]any{"content": "r"})
|
||||
}
|
||||
collect := func(tail int) (more any, users int) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
c.SubscribeTail(ctx, 0, tail, "", func(m map[string]any) bool {
|
||||
if v, ok := m["history_more"]; ok {
|
||||
more = v
|
||||
}
|
||||
if _, ok := m["user"]; ok {
|
||||
users++
|
||||
}
|
||||
if _, ok := m["caught_up"]; ok {
|
||||
cancel()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return
|
||||
}
|
||||
if more, users := collect(2); more != 3 || users != 2 {
|
||||
t.Fatalf("tail=2 : history_more=%v, %d échanges rejoués (attendu 3 et 2)", more, users)
|
||||
}
|
||||
if more, users := collect(-1); more != nil || users != 5 {
|
||||
t.Fatalf("ancien client : history_more=%v, %d échanges (attendu rien et 5)", more, users)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// chat_images.go — images d'une conversation stockées PAR RÉFÉRENCE.
|
||||
//
|
||||
// Une image vue par le modèle (pièce jointe, see_image, capture du navigateur) est
|
||||
// une partie `image_url` en base64 dans un message. Gardée telle quelle dans
|
||||
// l'historique, elle y pesait des Mo (une conversation de 14 messages : 36 Mo), et
|
||||
// l'historique entier est réécrit à chaque fin de tour et relu à chaque ouverture.
|
||||
//
|
||||
// On la range donc UNE fois sur disque, sous LOKI_HOME/chatimg/<empreinte>.<ext>
|
||||
// (hors du dossier de travail, que l'IA peut vider), et l'historique n'en garde
|
||||
// que l'adresse « loki-img:<nom> ». Juste avant l'envoi au modèle,
|
||||
// expandImageRefs remet les octets exacts (cache mémoire) : pour le modèle, rien
|
||||
// ne change. Mêmes octets, même empreinte → aucune copie en double.
|
||||
|
||||
const imgRefScheme = "loki-img:"
|
||||
|
||||
func chatImgDir() string { return filepath.Join(LokiHome(), "chatimg") }
|
||||
|
||||
var imgExtByMime = map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp"}
|
||||
|
||||
// storeChatImage range les octets d'une image et renvoie sa référence.
|
||||
func storeChatImage(b []byte, mime string) (string, error) {
|
||||
ext := imgExtByMime[mime]
|
||||
if ext == "" {
|
||||
ext = ".img"
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
name := hex.EncodeToString(sum[:16]) + ext
|
||||
dir := chatImgDir()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
// Chiffrée comme le fil quand la mémoire l'est (même enveloppe que les
|
||||
// pages). Verrouillée : refus, l'image reste en base64 dans le message.
|
||||
enc, err := encodeMemContent(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, enc, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, p); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return imgRefScheme + name, nil
|
||||
}
|
||||
|
||||
// Cache des data-URL reconstituées : un tour d'agent renvoie tout l'historique à
|
||||
// CHAQUE étape, on ne relit pas le disque à chaque fois. Borné en taille.
|
||||
var (
|
||||
imgCacheMu sync.Mutex
|
||||
imgCache = map[string]string{}
|
||||
imgCacheOrder []string
|
||||
imgCacheBytes int
|
||||
)
|
||||
|
||||
const imgCacheMax = 64 << 20
|
||||
|
||||
func chatImageDataURL(ref string) (string, bool) {
|
||||
name := strings.TrimPrefix(ref, imgRefScheme)
|
||||
if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||||
return "", false
|
||||
}
|
||||
imgCacheMu.Lock()
|
||||
if u, ok := imgCache[name]; ok {
|
||||
imgCacheMu.Unlock()
|
||||
return u, true
|
||||
}
|
||||
imgCacheMu.Unlock()
|
||||
raw, err := os.ReadFile(filepath.Join(chatImgDir(), name))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
b, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
mime := "image/jpeg"
|
||||
for m, e := range imgExtByMime {
|
||||
if strings.HasSuffix(name, e) {
|
||||
mime = m
|
||||
}
|
||||
}
|
||||
u := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b)
|
||||
imgCacheMu.Lock()
|
||||
if _, ok := imgCache[name]; !ok {
|
||||
imgCache[name] = u
|
||||
imgCacheOrder = append(imgCacheOrder, name)
|
||||
imgCacheBytes += len(u)
|
||||
for imgCacheBytes > imgCacheMax && len(imgCacheOrder) > 1 {
|
||||
old := imgCacheOrder[0]
|
||||
imgCacheOrder = imgCacheOrder[1:]
|
||||
imgCacheBytes -= len(imgCache[old])
|
||||
delete(imgCache, old)
|
||||
}
|
||||
}
|
||||
imgCacheMu.Unlock()
|
||||
return u, true
|
||||
}
|
||||
|
||||
// chatImgKeep : âge minimal d'une image avant qu'on puisse l'effacer.
|
||||
const chatImgKeep = 24 * time.Hour
|
||||
|
||||
// pruneChatImages efface les images de plus de chatImgKeep. Appelée au
|
||||
// démarrage et à chaque bascule de discussion, c'est-à-dire quand le fil
|
||||
// vivant vient d'être rechargé — et stripImageParts retire les images d'un fil
|
||||
// rechargé : plus rien ne référence alors les fichiers. Le délai couvre un
|
||||
// fil resté ouvert longtemps sans bascule.
|
||||
func pruneChatImages() {
|
||||
dir := chatImgDir()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-chatImgKeep)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
|
||||
_ = os.Remove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// contentParts renvoie les parties d'un contenu multimodal, sous l'une ou l'autre
|
||||
// forme (fraîche []map, ou relue du JSON []any).
|
||||
func contentParts(content any) []map[string]any {
|
||||
switch v := content.(type) {
|
||||
case []map[string]any:
|
||||
return v
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(v))
|
||||
for _, p := range v {
|
||||
if m, ok := p.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func partImageURL(p map[string]any) (map[string]any, string) {
|
||||
if p["type"] != "image_url" {
|
||||
return nil, ""
|
||||
}
|
||||
iu, _ := p["image_url"].(map[string]any)
|
||||
if iu == nil {
|
||||
return nil, ""
|
||||
}
|
||||
u, _ := iu["url"].(string)
|
||||
return iu, u
|
||||
}
|
||||
|
||||
// refImagesInMessages remplace EN PLACE les images en base64 des messages par des
|
||||
// références (voir plus haut). Renvoie true si quelque chose a changé. Une image
|
||||
// qui ne peut pas être rangée (disque plein…) reste en base64 : rien ne se perd.
|
||||
//
|
||||
// ⚠️ Écart avec l'amont : les parties modifiées sont RECOPIÉES, jamais mutées.
|
||||
// persist peut tourner pendant qu'un runChat (compactage en vol, bascule de
|
||||
// projet) sérialise encore ces mêmes maps : une écriture en place y déclenchait
|
||||
// un « concurrent map read and map write », fatal pour le process.
|
||||
func refImagesInMessages(msgs []Message) bool {
|
||||
changed := false
|
||||
for mi, m := range msgs {
|
||||
parts := contentParts(m.Content)
|
||||
var np []map[string]any
|
||||
for pi, p := range parts {
|
||||
_, u := partImageURL(p)
|
||||
if !strings.HasPrefix(u, "data:image/") {
|
||||
continue
|
||||
}
|
||||
i := strings.Index(u, ";base64,")
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(u[i+8:])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ref, err := storeChatImage(b, u[5:i])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if np == nil {
|
||||
np = append([]map[string]any(nil), parts...)
|
||||
}
|
||||
np[pi] = map[string]any{"type": "image_url", "image_url": map[string]any{"url": ref}}
|
||||
}
|
||||
if np != nil {
|
||||
msgs[mi].Content = np
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// hasInlineImages : au moins une image encore en base64 dans ces messages ?
|
||||
func hasInlineImages(msgs []Message) bool {
|
||||
for _, m := range msgs {
|
||||
for _, p := range contentParts(m.Content) {
|
||||
if _, u := partImageURL(p); strings.HasPrefix(u, "data:image/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// expandImageRefs renvoie une COPIE des messages prête pour le modèle : chaque
|
||||
// référence d'image redevient sa data-URL. Les messages sans référence sont
|
||||
// partagés tels quels. Une image introuvable (dossier supprimé à la main) devient
|
||||
// une mention textuelle plutôt qu'une requête qui échouerait.
|
||||
func expandImageRefs(msgs []Message) []Message {
|
||||
out := msgs
|
||||
copied := false
|
||||
for i, m := range msgs {
|
||||
parts := contentParts(m.Content)
|
||||
has := false
|
||||
for _, p := range parts {
|
||||
if _, u := partImageURL(p); strings.HasPrefix(u, imgRefScheme) {
|
||||
has = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
if !copied {
|
||||
out = append([]Message(nil), msgs...)
|
||||
copied = true
|
||||
}
|
||||
np := make([]map[string]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
_, u := partImageURL(p)
|
||||
if !strings.HasPrefix(u, imgRefScheme) {
|
||||
np = append(np, p)
|
||||
continue
|
||||
}
|
||||
if d, ok := chatImageDataURL(u); ok {
|
||||
np = append(np, map[string]any{"type": "image_url", "image_url": map[string]any{"url": d}})
|
||||
} else {
|
||||
np = append(np, map[string]any{"type": "text", "text": "[image indisponible : fichier supprimé]"})
|
||||
}
|
||||
}
|
||||
m.Content = np
|
||||
out[i] = m
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Une image en base64 part sur disque et revient identique à l'envoi au modèle ;
|
||||
// l'historique ne garde qu'une référence de quelques octets.
|
||||
func TestImageRefsRoundTrip(t *testing.T) {
|
||||
testHome(t)
|
||||
raw := []byte(strings.Repeat("\x89PNG-fake-image-bytes", 5000))
|
||||
url := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
|
||||
msgs := []Message{
|
||||
{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "regarde"},
|
||||
{"type": "image_url", "image_url": map[string]any{"url": url}},
|
||||
}},
|
||||
{Role: "assistant", Content: "vu"},
|
||||
}
|
||||
// Relu depuis le JSON (forme []any), comme après un redémarrage.
|
||||
var reread []Message
|
||||
b, _ := json.Marshal(msgs)
|
||||
_ = json.Unmarshal(b, &reread)
|
||||
|
||||
for _, set := range [][]Message{msgs, reread} {
|
||||
if !hasInlineImages(set) || !refImagesInMessages(set) || hasInlineImages(set) {
|
||||
t.Fatal("l'image aurait dû être rangée par référence")
|
||||
}
|
||||
js, _ := json.Marshal(set)
|
||||
if len(js) > 500 || !strings.Contains(string(js), imgRefScheme) {
|
||||
t.Fatalf("historique encore lourd : %d octets", len(js))
|
||||
}
|
||||
out := expandImageRefs(set)
|
||||
_, got := partImageURL(contentParts(out[0].Content)[1])
|
||||
if got != url {
|
||||
t.Fatal("l'image renvoyée au modèle diffère de l'originale")
|
||||
}
|
||||
// L'historique lui-même n'est pas modifié par l'expansion.
|
||||
if _, u := partImageURL(contentParts(set[0].Content)[1]); !strings.HasPrefix(u, imgRefScheme) {
|
||||
t.Fatal("expandImageRefs ne doit pas toucher l'historique")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mémoire chiffrée : l'image rangée est chiffrée sur disque et revient intacte ;
|
||||
// verrouillée, elle n'est pas écrite (elle reste en base64 dans le message).
|
||||
func TestImageRefsChiffrees(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
|
||||
t.Fatalf("EnableMemEncryption: %v", err)
|
||||
}
|
||||
raw := []byte(strings.Repeat("\xff\xd8JPEG-fake", 3000))
|
||||
ref, err := storeChatImage(raw, "image/jpeg")
|
||||
if err != nil {
|
||||
t.Fatalf("storeChatImage: %v", err)
|
||||
}
|
||||
disk, _ := os.ReadFile(filepath.Join(chatImgDir(), strings.TrimPrefix(ref, imgRefScheme)))
|
||||
if !looksEncrypted(disk) {
|
||||
t.Fatal("image en clair sur disque alors que la mémoire est chiffrée")
|
||||
}
|
||||
u, ok := chatImageDataURL(ref)
|
||||
if !ok || u != "data:image/jpeg;base64,"+base64.StdEncoding.EncodeToString(raw) {
|
||||
t.Fatal("image chiffrée non restituée à l'identique")
|
||||
}
|
||||
clearMemDEK()
|
||||
if _, err := storeChatImage([]byte("autre image"), "image/png"); err == nil {
|
||||
t.Fatal("mémoire verrouillée : l'image n'aurait pas dû être écrite")
|
||||
}
|
||||
}
|
||||
|
||||
// Les images de plus de chatImgKeep sont effacées, les récentes restent.
|
||||
func TestPruneChatImages(t *testing.T) {
|
||||
testHome(t)
|
||||
vieille, _ := storeChatImage([]byte("vieille"), "image/png")
|
||||
recente, _ := storeChatImage([]byte("récente"), "image/png")
|
||||
pv := filepath.Join(chatImgDir(), strings.TrimPrefix(vieille, imgRefScheme))
|
||||
old := time.Now().Add(-chatImgKeep - time.Hour)
|
||||
if err := os.Chtimes(pv, old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pruneChatImages()
|
||||
if _, err := os.Stat(pv); !os.IsNotExist(err) {
|
||||
t.Fatal("la vieille image aurait dû être effacée")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(chatImgDir(), strings.TrimPrefix(recente, imgRefScheme))); err != nil {
|
||||
t.Fatal("la récente image a disparu")
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func webGrepTool() Tool {
|
||||
// ─── exécution des outils (appelée par le dispatch de llm_client.go) ───────────────
|
||||
|
||||
// webMaxOutput borne ce qu'UN appel d'outil web injecte dans le contexte, comme
|
||||
// toolMaxOutput (8000) pour le shell et mcpMaxOutput (12000) pour MCP. Sans ce
|
||||
// toolMaxOutput (8000) pour le shell et mcpOutputCap pour MCP. Sans ce
|
||||
// plafond, un `web_read(limit=500)` sur une page dense pouvait pousser 25 000
|
||||
// caractères d'un coup : la fenêtre partait en fumée en pleine recherche, ce qui
|
||||
// déclenchait des compactages en cascade au milieu du raisonnement.
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
package loki
|
||||
|
||||
// chat_keep_images.go — KEEP_TURN_IMAGES : garder dans l'historique les images
|
||||
// que montrent les outils (web_screenshot, browser_screenshot, see_image), au
|
||||
// lieu de les jeter à la fin du tour. Opt-in, off par défaut, ZONE GRISE.
|
||||
//
|
||||
// Sans la clé, ces images sont ÉPHÉMÈRES (voir runChat) : le modèle les voit
|
||||
// pendant le tour, puis elles disparaissent de l'historique. Le tour suivant
|
||||
// renvoie donc un fil où elles manquent : le préfixe que le moteur a en cache
|
||||
// diverge à la première, et toute la boucle d'outils qui suivait — souvent des
|
||||
// dizaines d'étapes de navigation — est recalculée.
|
||||
//
|
||||
// Avec la clé, une image est gardée telle qu'envoyée, sous conditions :
|
||||
//
|
||||
// - vision réellement active (visionEnabled et engineSeesImages, revérifiés à
|
||||
// chaque tour : un preset sans vision n'en reçoit jamais) ; un preset
|
||||
// externe n'est concerné que si sa vision est déclarée, et chaque image
|
||||
// gardée y est alors refacturée à chaque tour ;
|
||||
// - rangée par référence (chat_images.go, chiffrée si la mémoire l'est) et
|
||||
// relue à l'octet près avant d'être gardée : le modèle reçoit les mêmes
|
||||
// octets à chaque envoi ;
|
||||
// - coût MESURÉ par le moteur (écart de usage.prompt_tokens entre deux
|
||||
// requêtes, moins l'estimation du texte ajouté) et non estimé : une image
|
||||
// dont le coût ne se mesure pas (moteur sans usage, requête relancée,
|
||||
// historique réécrit entre-temps) reste éphémère ;
|
||||
// - toutes les images gardées tiennent dans keepImgPct % de la fenêtre. La
|
||||
// première qui ne tient pas reste éphémère, et toutes celles qui la suivent
|
||||
// dans le tour aussi : le préfixe divergera de toute façon à elle ;
|
||||
// - retirées D'ABORD par toute compaction ou réduction forcée (légende et
|
||||
// imageLostMarker gardés), avant tout résumé ; un rechargement du fil les
|
||||
// retire aussi (stripImageParts), comme les pièces jointes.
|
||||
//
|
||||
// Ce que ça coûte : plus d'information gardée, donc une compaction (avec
|
||||
// perte) qui arrive plus tôt — d'où la borne et le retrait prioritaire.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// keepImgPct : part maximale de la fenêtre que les images gardées occupent.
|
||||
const keepImgPct = 10
|
||||
|
||||
// keepImgMinTokens : en dessous, la mesure ne vaut rien (écart noyé dans
|
||||
// l'erreur d'estimation du texte) — l'image reste éphémère.
|
||||
const keepImgMinTokens = 16
|
||||
|
||||
func keepTurnImagesOn(cfg map[string]string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(cfg["KEEP_TURN_IMAGES"])) {
|
||||
case "on", "1", "true", "yes", "oui":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func keepImgBudget() int { return ctxWindow() * keepImgPct / 100 }
|
||||
|
||||
// keptImageTokens : coût total des images gardées de ces messages.
|
||||
func keptImageTokens(msgs []Message) int {
|
||||
n := 0
|
||||
for _, m := range msgs {
|
||||
n += m.imgTokens
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func hasKeptImages(msgs []Message) bool {
|
||||
for _, m := range msgs {
|
||||
if m.imgTokens > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dropKeptImages renvoie une COPIE des messages où chaque image gardée devient
|
||||
// sa légende suivie d'imageLostMarker (la forme de stripImageParts), et le coût
|
||||
// libéré. Rien de gardé : msgs tel quel, 0. Le message reste un relais
|
||||
// (ImgRelay) : ni une demande, ni la preuve qu'une demande a été servie.
|
||||
func dropKeptImages(msgs []Message) ([]Message, int) {
|
||||
var out []Message
|
||||
freed := 0
|
||||
for i, m := range msgs {
|
||||
if m.imgTokens <= 0 {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = append([]Message(nil), msgs...)
|
||||
}
|
||||
var texts []string
|
||||
for _, p := range contentParts(m.Content) {
|
||||
if t, ok := p["text"].(string); ok && p["type"] == "text" {
|
||||
texts = append(texts, t)
|
||||
}
|
||||
}
|
||||
freed += m.imgTokens
|
||||
m.Content = strings.Join(texts, "\n") + imageLostMarker
|
||||
m.imgTokens = 0
|
||||
out[i] = m
|
||||
}
|
||||
if out == nil {
|
||||
return msgs, 0
|
||||
}
|
||||
return out, freed
|
||||
}
|
||||
|
||||
func logKeptImagesDropped(phase string, freed int) {
|
||||
fmt.Fprintf(os.Stderr, "[images] %s : images gardées retirées (%d jetons)\n", phase, freed)
|
||||
}
|
||||
|
||||
// stripRelayTags retire la marque ImgRelay avant l'envoi : propre à Loki, elle
|
||||
// n'a rien à faire dans la requête. Aucune marque : msgs tel quel, sans copie.
|
||||
func stripRelayTags(msgs []Message) []Message {
|
||||
var out []Message
|
||||
for i, m := range msgs {
|
||||
// La marque des blocs <context_update> (PROJ_SNAPSHOT) non plus ne
|
||||
// quitte jamais Loki.
|
||||
if !m.ImgRelay && !m.CtxUpd {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = append([]Message(nil), msgs...)
|
||||
}
|
||||
m.ImgRelay, m.CtxUpd = false, false
|
||||
out[i] = m
|
||||
}
|
||||
if out == nil {
|
||||
return msgs
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// refRelayImage range l'image d'un relais par référence et renvoie le message
|
||||
// qui la porte ainsi, avec le poids de l'image (octets, pour répartir une
|
||||
// mesure entre plusieurs images d'une même étape). ok=false — format sans
|
||||
// extension connue, disque plein, mémoire chiffrée verrouillée, relecture qui
|
||||
// ne redonne pas exactement la même data-URL — : l'image reste éphémère.
|
||||
func refRelayImage(m Message) (Message, int, bool) {
|
||||
parts := contentParts(m.Content)
|
||||
np := make([]map[string]any, len(parts))
|
||||
weight := 0
|
||||
for i, p := range parts {
|
||||
np[i] = p
|
||||
_, u := partImageURL(p)
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
if weight > 0 || !strings.HasPrefix(u, "data:image/") {
|
||||
return m, 0, false
|
||||
}
|
||||
k := strings.Index(u, ";base64,")
|
||||
if k < 0 {
|
||||
return m, 0, false
|
||||
}
|
||||
mime := u[5:k]
|
||||
if imgExtByMime[mime] == "" {
|
||||
return m, 0, false
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(u[k+8:])
|
||||
if err != nil || len(b) == 0 {
|
||||
return m, 0, false
|
||||
}
|
||||
ref, err := storeChatImage(b, mime)
|
||||
if err != nil {
|
||||
return m, 0, false
|
||||
}
|
||||
// Gardée en vie : storeChatImage ne réécrit pas un fichier déjà là (mêmes
|
||||
// octets), et pruneChatImages efface au bout de chatImgKeep d'après la
|
||||
// date du fichier — une image revue aujourd'hui ne doit pas tomber demain
|
||||
// en « [image indisponible] » au milieu d'un fil.
|
||||
now := time.Now()
|
||||
_ = os.Chtimes(filepath.Join(chatImgDir(), strings.TrimPrefix(ref, imgRefScheme)), now, now)
|
||||
if back, ok := chatImageDataURL(ref); !ok || back != u {
|
||||
return m, 0, false
|
||||
}
|
||||
np[i] = map[string]any{"type": "image_url", "image_url": map[string]any{"url": ref}}
|
||||
weight = len(b)
|
||||
}
|
||||
if weight == 0 {
|
||||
return m, 0, false
|
||||
}
|
||||
m.Content = np
|
||||
return m, weight, true
|
||||
}
|
||||
|
||||
// keepImages : l'état de KEEP_TURN_IMAGES pendant UN tour (runChat). nil = clé
|
||||
// absente, tâche, sous-agent, vérification ou vision inactive : toutes ses
|
||||
// méthodes rendent alors le comportement d'avant.
|
||||
type keepImages struct {
|
||||
// stopped : une image de ce tour n'a pas été gardée ; les suivantes ne le
|
||||
// sont pas non plus (le préfixe du tour suivant diverge à elle).
|
||||
stopped bool
|
||||
// Images ajoutées à l'étape courante, gardées à l'essai jusqu'à la mesure
|
||||
// de la requête qui les montre la première.
|
||||
pend []keepPend
|
||||
// pendReq : numéro de cette requête. pendBase : contexte que la complétion
|
||||
// précédente laisse (ctxAfter). pendFrom : premier message ajouté depuis.
|
||||
pendReq, pendBase, pendFrom int
|
||||
// reqN : requêtes envoyées ; sentLen : messages de la dernière.
|
||||
reqN, sentLen int
|
||||
// lastAfter : ctxAfter de la dernière complétion complète (0 = inconnu).
|
||||
lastAfter int
|
||||
}
|
||||
|
||||
type keepPend struct{ msgIdx, extraIdx, weight int }
|
||||
|
||||
// newKeepImages : l'état du tour, ou nil. Fil principal d'une discussion
|
||||
// seulement : les tâches, sous-agents et la vérification ne persistent pas
|
||||
// leur historique comme elle.
|
||||
func newKeepImages(cfg map[string]string, tag perfTag) *keepImages {
|
||||
if !keepTurnImagesOn(cfg) || tag.kind != perfMain || tag.conv == "" {
|
||||
return nil
|
||||
}
|
||||
if !visionEnabled() || !engineSeesImages() {
|
||||
return nil
|
||||
}
|
||||
return &keepImages{}
|
||||
}
|
||||
|
||||
// relay ajoute le message qui relaie une image d'outil. Sans état : à la vue
|
||||
// du modèle seulement, comme avant. Avec : gardé à l'essai dans extra aussi,
|
||||
// par référence, si le budget n'est pas déjà plein. from = début des messages
|
||||
// de l'étape (premier résultat d'outil).
|
||||
func (k *keepImages) relay(messages, extra []Message, m Message, from int) ([]Message, []Message) {
|
||||
if k == nil {
|
||||
return append(messages, m), extra
|
||||
}
|
||||
m.ImgRelay = true
|
||||
if k.stopped {
|
||||
return append(messages, m), extra
|
||||
}
|
||||
// Budget déjà plein : inutile de ranger le fichier.
|
||||
r, w, ok := Message{}, 0, false
|
||||
if keptImageTokens(messages) < keepImgBudget() {
|
||||
r, w, ok = refRelayImage(m)
|
||||
}
|
||||
if !ok {
|
||||
k.stopped = true
|
||||
return append(messages, m), extra
|
||||
}
|
||||
if len(k.pend) == 0 {
|
||||
k.pendReq, k.pendBase, k.pendFrom = k.reqN+1, k.lastAfter, from
|
||||
}
|
||||
messages = append(messages, r)
|
||||
extra = append(extra, r)
|
||||
k.pend = append(k.pend, keepPend{msgIdx: len(messages) - 1, extraIdx: len(extra) - 1, weight: w})
|
||||
return messages, extra
|
||||
}
|
||||
|
||||
// sending : une requête part avec n messages.
|
||||
func (k *keepImages) sending(n int) {
|
||||
if k == nil {
|
||||
return
|
||||
}
|
||||
k.reqN++
|
||||
k.sentLen = n
|
||||
}
|
||||
|
||||
// observe : une complétion est lue. Mesure le coût des images à l'essai si
|
||||
// c'est la requête qui les a montrées la première, et les garde si le budget
|
||||
// le permet ; sinon elles redeviennent éphémères (retirées d'extra). Renvoie
|
||||
// extra.
|
||||
func (k *keepImages) observe(complete bool, st StatsEvent, messages, extra []Message) []Message {
|
||||
if k == nil {
|
||||
return extra
|
||||
}
|
||||
after := 0
|
||||
if complete && st.PromptTokensTotal > 0 {
|
||||
after = st.ctxAfter()
|
||||
}
|
||||
if len(k.pend) > 0 {
|
||||
if !k.measure(after, st.PromptTokensTotal, messages, extra) {
|
||||
extra = k.reject(extra)
|
||||
}
|
||||
k.pend = nil
|
||||
}
|
||||
k.lastAfter = after
|
||||
return extra
|
||||
}
|
||||
|
||||
// measure répartit le coût mesuré entre les images à l'essai et les garde si
|
||||
// tout tient dans le budget. false = mesure impossible ou budget dépassé.
|
||||
func (k *keepImages) measure(after, prompt int, messages, extra []Message) bool {
|
||||
if after <= 0 || k.pendBase <= 0 || k.pendReq != k.reqN ||
|
||||
k.pendFrom > k.sentLen || k.sentLen > len(messages) {
|
||||
return false
|
||||
}
|
||||
// Ce que la requête a ajouté depuis la précédente, hors images : résultats
|
||||
// d'outils, légendes exceptées, ajouts en cours de réponse, rappels.
|
||||
text := 0
|
||||
for _, m := range messages[k.pendFrom:k.sentLen] {
|
||||
if !m.ImgRelay {
|
||||
text += msgTokens(m)
|
||||
}
|
||||
}
|
||||
img := prompt - k.pendBase - text
|
||||
weight := 0
|
||||
for _, p := range k.pend {
|
||||
weight += p.weight
|
||||
}
|
||||
if weight <= 0 || img < keepImgMinTokens*len(k.pend) {
|
||||
return false
|
||||
}
|
||||
if keptImageTokens(messages)+img > keepImgBudget() {
|
||||
return false
|
||||
}
|
||||
shares := make([]int, len(k.pend))
|
||||
left := img
|
||||
for i, p := range k.pend {
|
||||
shares[i] = img * p.weight / weight
|
||||
if i == len(k.pend)-1 {
|
||||
shares[i] = left
|
||||
}
|
||||
if shares[i] < keepImgMinTokens {
|
||||
return false
|
||||
}
|
||||
left -= shares[i]
|
||||
}
|
||||
for _, p := range k.pend {
|
||||
if p.msgIdx >= len(messages) || p.extraIdx >= len(extra) || !messages[p.msgIdx].ImgRelay || !extra[p.extraIdx].ImgRelay {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for i, p := range k.pend {
|
||||
messages[p.msgIdx].imgTokens = shares[i]
|
||||
extra[p.extraIdx].imgTokens = shares[i]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// reject retire d'extra les images à l'essai : éphémères, comme sans la clé.
|
||||
func (k *keepImages) reject(extra []Message) []Message {
|
||||
if len(k.pend) == 0 {
|
||||
return extra
|
||||
}
|
||||
k.stopped = true
|
||||
drop := map[int]bool{}
|
||||
for _, p := range k.pend {
|
||||
drop[p.extraIdx] = true
|
||||
}
|
||||
out := make([]Message, 0, len(extra))
|
||||
for i, m := range extra {
|
||||
if !drop[i] {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
k.pend = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// rewritten : l'historique vient d'être réécrit en cours de tour (compaction,
|
||||
// réduction) et extra repart de zéro. Les images à l'essai ne se mesurent plus
|
||||
// (ni base ni position) : éphémères, et le tour s'arrête là pour les suivantes.
|
||||
func (k *keepImages) rewritten() {
|
||||
if k == nil {
|
||||
return
|
||||
}
|
||||
if len(k.pend) > 0 {
|
||||
k.stopped = true
|
||||
}
|
||||
k.pend = nil
|
||||
}
|
||||
|
||||
// publishable : l'historique publié par une réécriture en cours de tour, sans
|
||||
// les relais dont l'image n'est pas gardée (éphémères, ou à l'essai). Sans
|
||||
// état, tel quel — comportement d'avant.
|
||||
func (k *keepImages) publishable(msgs []Message) []Message {
|
||||
if k == nil {
|
||||
return msgs
|
||||
}
|
||||
out := msgs[:0:0]
|
||||
for _, m := range msgs {
|
||||
if m.ImgRelay && m.imgTokens == 0 && hasImagePart(m) {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// finish : fin du tour. Une image encore à l'essai (tour arrêté, erreur, fin
|
||||
// juste après elle) n'a pas été mesurée : éphémère.
|
||||
func (k *keepImages) finish(extra []Message) []Message {
|
||||
if k == nil {
|
||||
return extra
|
||||
}
|
||||
return k.reject(extra)
|
||||
}
|
||||
|
||||
func hasImagePart(m Message) bool {
|
||||
for _, p := range contentParts(m.Content) {
|
||||
if p["type"] == "image_url" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// keptImagesTurnStart : début d'un tour de discussion. Les images gardées
|
||||
// sont retirées d'abord — avant toute compaction, qui ne résume alors que si
|
||||
// c'est encore nécessaire — quand le contexte la réclame, ou quand elles ne
|
||||
// doivent plus partir : clé retirée, vision absente (un moteur sans projecteur
|
||||
// refuserait la requête). Renvoie l'historique et le contexte à jour.
|
||||
func (c *Conversation) keptImagesTurnStart(epoch int, msgs []Message, ctxUsed, peak int) ([]Message, int) {
|
||||
if !hasKeptImages(msgs) {
|
||||
return msgs, ctxUsed
|
||||
}
|
||||
if keepTurnImagesOn(ReadConfig()) && visionEnabled() && engineSeesImages() && !compactNeeded(msgs, ctxUsed, peak) {
|
||||
return msgs, ctxUsed
|
||||
}
|
||||
out, freed := dropKeptImages(msgs)
|
||||
logKeptImagesDropped("début-tour", freed)
|
||||
if ctxUsed > 0 {
|
||||
ctxUsed = max(ctxUsed-freed, 1)
|
||||
}
|
||||
c.mu.Lock()
|
||||
cur := 0
|
||||
if c.epoch == epoch {
|
||||
c.Messages = append([]Message(nil), out...)
|
||||
if c.CtxUsed > 0 {
|
||||
c.CtxUsed = max(c.CtxUsed-freed, 1)
|
||||
}
|
||||
cur = c.CtxUsed
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if cur > 0 {
|
||||
c.appendDelta(epoch, map[string]any{"ctx_used": cur})
|
||||
}
|
||||
return out, ctxUsed
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sseSeeImage : le modèle demande see_image sur img.png ; usage du prompt
|
||||
// prompt (complétion de 20 jetons). prompt = 0 : aucun usage, comme un moteur
|
||||
// qui ne le renvoie pas.
|
||||
func sseSeeImage(prompt int) string {
|
||||
s := `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"v1","type":"function","function":{"name":"see_image","arguments":"{\"file\":\"img.png\"}"}}]}}]}` + "\n\n" +
|
||||
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"
|
||||
if prompt > 0 {
|
||||
s += fmt.Sprintf(`data: {"choices":[],"usage":{"prompt_tokens":%d,"completion_tokens":20}}`, prompt) + "\n\n"
|
||||
}
|
||||
return s + "data: [DONE]\n\n"
|
||||
}
|
||||
|
||||
func sseAnswer(text string, prompt int) string {
|
||||
s := sseChunk(text) + `data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"
|
||||
if prompt > 0 {
|
||||
s += fmt.Sprintf(`data: {"choices":[],"usage":{"prompt_tokens":%d,"completion_tokens":5}}`, prompt) + "\n\n"
|
||||
}
|
||||
return s + "data: [DONE]\n\n"
|
||||
}
|
||||
|
||||
// visionServer : un llama-server de test qui déclare la vision (/props) et
|
||||
// répond aux complétions dans l'ordre des steps, en gardant chaque corps.
|
||||
func visionServer(t *testing.T, steps ...string) func() []map[string]any {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var bodies []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/props" {
|
||||
_, _ = w.Write([]byte(`{"modalities":{"vision":true}}`))
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
mu.Lock()
|
||||
bodies = append(bodies, body)
|
||||
n := len(bodies) - 1
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(steps[min(n, len(steps)-1)]))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
for k, v := range map[string]string{"PORT": u.Port(), "MMPROJ": "/models/mmproj.gguf"} {
|
||||
if err := SetConfigKey(k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Sonde de vision en cache 10 s : un autre test a pu conclure « non ».
|
||||
visionProbeMu.Lock()
|
||||
visionProbeAt = time.Time{}
|
||||
visionProbeMu.Unlock()
|
||||
return func() []map[string]any {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]map[string]any(nil), bodies...)
|
||||
}
|
||||
}
|
||||
|
||||
// writeTestPNG pose img.png (petit, sous maxImageDim : octets inchangés) dans
|
||||
// le dossier de travail et renvoie la data-URL que le modèle doit recevoir.
|
||||
func writeTestPNG(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 8, 8))
|
||||
for i := 0; i < 8; i++ {
|
||||
img.Set(i, i, color.RGBA{R: 200, A: 255})
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "img.png"), buf.Bytes(), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
part := imageURLPart(buf.Bytes(), "image/png")
|
||||
return part["image_url"].(map[string]any)["url"].(string)
|
||||
}
|
||||
|
||||
func mainCtx(t *testing.T) context.Context {
|
||||
return withPerf(t.Context(), perfMain, "conv-test")
|
||||
}
|
||||
|
||||
// relayOf : le message de relais d'une requête (celui qui porte l'image).
|
||||
func relayOf(t *testing.T, body map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
for _, m := range body["messages"].([]any) {
|
||||
mm := m.(map[string]any)
|
||||
if parts, ok := mm["content"].([]any); ok && len(parts) == 2 {
|
||||
return mm
|
||||
}
|
||||
}
|
||||
t.Fatal("aucun relais d'image dans la requête")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sans la clé : image éphémère, relais en base64, aucune marque — et, avec la
|
||||
// clé, la requête qui montre l'image est la MÊME à l'octet près.
|
||||
func TestKeepImagesSansCleRequeteIdentique(t *testing.T) {
|
||||
dir := withWorkspace(t)
|
||||
want := writeTestPNG(t, dir)
|
||||
|
||||
reqs := visionServer(t, sseSeeImage(1000), sseAnswer("vu", 1331))
|
||||
extra, err := runChat(mainCtx(t), []Message{um("regarde img.png")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
off := reqs()
|
||||
if len(off) != 2 {
|
||||
t.Fatalf("%d requêtes, attendu 2", len(off))
|
||||
}
|
||||
relay := relayOf(t, off[1])
|
||||
if _, tagged := relay["img_relay"]; tagged {
|
||||
t.Fatal("marque img_relay envoyée sans la clé")
|
||||
}
|
||||
part := relay["content"].([]any)[1].(map[string]any)
|
||||
if got := part["image_url"].(map[string]any)["url"]; got != want {
|
||||
t.Fatal("image du relais différente de celle du fichier")
|
||||
}
|
||||
if len(extra) != 2 {
|
||||
t.Fatalf("sans la clé, l'image doit rester éphémère : %+v", extra)
|
||||
}
|
||||
for _, m := range extra {
|
||||
if m.ImgRelay || m.imgTokens != 0 {
|
||||
t.Fatalf("message marqué sans la clé : %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reqs = visionServer(t, sseSeeImage(1000), sseAnswer("vu", 1331))
|
||||
if _, err := runChat(mainCtx(t), []Message{um("regarde img.png")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
on := reqs()
|
||||
if len(on) != 2 {
|
||||
t.Fatalf("%d requêtes avec la clé, attendu 2", len(on))
|
||||
}
|
||||
for i := range on {
|
||||
if !reflect.DeepEqual(on[i], off[i]) {
|
||||
a, _ := json.Marshal(off[i])
|
||||
b, _ := json.Marshal(on[i])
|
||||
t.Fatalf("requête %d différente avec la clé :\n%s\n%s", i, a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Avec la clé : l'image est gardée par référence, son coût est celui que le
|
||||
// moteur a mesuré (écart de prompt moins le texte ajouté), et le tour suivant
|
||||
// renvoie exactement les mêmes octets.
|
||||
func TestKeepImagesGardeeEtMesuree(t *testing.T) {
|
||||
dir := withWorkspace(t)
|
||||
want := writeTestPNG(t, dir)
|
||||
if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
toolMsg := Message{Role: "tool", ToolCallID: "v1", Content: "[ok] image chargée : img.png"}
|
||||
// ctxAfter de la 1re complétion = 1000 + 20 ; + résultat d'outil ; + 300
|
||||
// pour l'image.
|
||||
prompt2 := 1020 + msgTokens(toolMsg) + 300
|
||||
visionServer(t, sseSeeImage(1000), sseAnswer("vu", prompt2))
|
||||
extra, err := runChat(mainCtx(t), []Message{um("regarde img.png")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(extra) != 3 {
|
||||
t.Fatalf("image non gardée : %+v", extra)
|
||||
}
|
||||
r := extra[2]
|
||||
if !r.ImgRelay || r.imgTokens != 300 {
|
||||
t.Fatalf("relais gardé = %+v, attendu marqué et 300 jetons", r)
|
||||
}
|
||||
_, u := partImageURL(contentParts(r.Content)[1])
|
||||
if !strings.HasPrefix(u, imgRefScheme) {
|
||||
t.Fatalf("image gardée en base64 et non par référence : %.60s", u)
|
||||
}
|
||||
if !isLokiInjected(r) {
|
||||
t.Fatal("relais pris pour une demande de l'utilisateur")
|
||||
}
|
||||
|
||||
// Tour suivant : l'historique renvoyé repart avec la même image, sans la
|
||||
// marque, à l'octet près de ce que le moteur a déjà vu.
|
||||
hist := append([]Message{um("regarde img.png")}, extra...)
|
||||
hist = append(hist, am("vu"), um("et maintenant ?"))
|
||||
reqs := visionServer(t, sseAnswer("ok", 0))
|
||||
if _, err := runChat(mainCtx(t), hist, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := reqs()[0]
|
||||
relay := relayOf(t, next)
|
||||
if _, tagged := relay["img_relay"]; tagged {
|
||||
t.Fatal("marque img_relay envoyée au moteur")
|
||||
}
|
||||
if got := relay["content"].([]any)[1].(map[string]any)["image_url"].(map[string]any)["url"]; got != want {
|
||||
t.Fatal("le tour suivant ne renvoie pas les mêmes octets d'image")
|
||||
}
|
||||
}
|
||||
|
||||
// Garde : au-delà de 10 % de la fenêtre, l'image reste éphémère.
|
||||
func TestKeepImagesBudget(t *testing.T) {
|
||||
dir := withWorkspace(t)
|
||||
writeTestPNG(t, dir)
|
||||
for k, v := range map[string]string{"KEEP_TURN_IMAGES": "on", "CTX": "2000"} {
|
||||
if err := SetConfigKey(k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
toolMsg := Message{Role: "tool", ToolCallID: "v1", Content: "[ok] image chargée : img.png"}
|
||||
visionServer(t, sseSeeImage(1000), sseAnswer("vu", 1020+msgTokens(toolMsg)+300))
|
||||
extra, err := runChat(mainCtx(t), []Message{um("regarde img.png")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(extra) != 2 {
|
||||
t.Fatalf("image de 300 jetons gardée malgré un budget de 200 : %+v", extra)
|
||||
}
|
||||
}
|
||||
|
||||
// Moteur sans usage : rien à mesurer, l'image reste éphémère.
|
||||
func TestKeepImagesSansMesure(t *testing.T) {
|
||||
dir := withWorkspace(t)
|
||||
writeTestPNG(t, dir)
|
||||
if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
visionServer(t, sseSeeImage(0), sseAnswer("vu", 0))
|
||||
extra, err := runChat(mainCtx(t), []Message{um("regarde img.png")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(extra) != 2 {
|
||||
t.Fatalf("image gardée sans mesure : %+v", extra)
|
||||
}
|
||||
}
|
||||
|
||||
// Ni tâche ni sous-agent : seul le fil d'une discussion garde ses images.
|
||||
func TestKeepImagesFilPrincipalSeulement(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if newKeepImages(ReadConfig(), perfTag{kind: perfTask, conv: "task:x"}) != nil ||
|
||||
newKeepImages(ReadConfig(), perfTag{kind: perfMain}) != nil {
|
||||
t.Fatal("état de KEEP_TURN_IMAGES hors d'une discussion")
|
||||
}
|
||||
var k *keepImages // clé absente : chaque méthode rend le comportement d'avant
|
||||
m := []Message{um("a")}
|
||||
got, e := k.relay(m, nil, um("img"), 1)
|
||||
if len(got) != 2 || e != nil || got[1].ImgRelay {
|
||||
t.Fatal("relais sans état modifié")
|
||||
}
|
||||
k.sending(2)
|
||||
k.rewritten()
|
||||
if k.observe(true, StatsEvent{}, got, nil) != nil || k.finish(nil) != nil ||
|
||||
len(k.publishable(got)) != 2 {
|
||||
t.Fatal("état nil qui agit")
|
||||
}
|
||||
}
|
||||
|
||||
func keptRelay(tokens int) Message {
|
||||
return Message{Role: "user", ImgRelay: true, imgTokens: tokens, Content: []map[string]any{
|
||||
{"type": "text", "text": "Voici la capture demandée."},
|
||||
{"type": "image_url", "image_url": map[string]any{"url": imgRefScheme + "abc.png"}},
|
||||
}}
|
||||
}
|
||||
|
||||
// Retrait : légende + imageLostMarker, toujours un relais, coût libéré ; rien
|
||||
// à faire sans image gardée.
|
||||
func TestDropKeptImages(t *testing.T) {
|
||||
plain := []Message{um("q"), tm("r")}
|
||||
if out, freed := dropKeptImages(plain); freed != 0 || &out[0] != &plain[0] {
|
||||
t.Fatal("copie ou retrait sans image gardée")
|
||||
}
|
||||
msgs := []Message{um("q"), atc("see_image"), tm("[ok]"), keptRelay(500), am("vu")}
|
||||
out, freed := dropKeptImages(msgs)
|
||||
if freed != 500 {
|
||||
t.Fatalf("libéré %d, attendu 500", freed)
|
||||
}
|
||||
r := out[3]
|
||||
if r.Content != "Voici la capture demandée."+imageLostMarker || !r.ImgRelay || r.imgTokens != 0 {
|
||||
t.Fatalf("relais retiré = %+v", r)
|
||||
}
|
||||
if msgs[3].imgTokens != 500 {
|
||||
t.Fatal("l'historique d'origine a été modifié en place")
|
||||
}
|
||||
if estimateTokens(msgs)-estimateTokens(out) < 400 {
|
||||
t.Fatal("le coût mesuré de l'image n'est pas compté dans l'estimation")
|
||||
}
|
||||
}
|
||||
|
||||
// Toute compaction retire les images gardées d'abord, et ce retrait compte
|
||||
// comme un changement même quand rien n'est à résumer.
|
||||
func TestCompactionRetireLesImagesDAbord(t *testing.T) {
|
||||
testHome(t)
|
||||
msgs := []Message{{Role: "system", Content: "sys"}, um("q"), atc("see_image"), tm("[ok]"), keptRelay(5000), am("vu")}
|
||||
out, changed, _ := compactMessagesOpt(t.Context(), msgs, Caps{}, compactOpts{})
|
||||
if !changed || hasKeptImages(out) {
|
||||
t.Fatalf("images gardées non retirées par la compaction : changé=%v", changed)
|
||||
}
|
||||
out, changed = shrinkToFit(msgs, 0)
|
||||
if !changed || hasKeptImages(out) {
|
||||
t.Fatal("images gardées non retirées par la réduction forcée")
|
||||
}
|
||||
// Sans image gardée : chemin d'avant, rien de changé sur un torse vide.
|
||||
plain := []Message{{Role: "system", Content: "sys"}, um("q"), am("r")}
|
||||
if _, changed, _ := compactMessagesOpt(t.Context(), plain, Caps{}, compactOpts{}); changed {
|
||||
t.Fatal("compaction d'un fil sans torse")
|
||||
}
|
||||
}
|
||||
|
||||
// Un relais n'est ni une demande à réinjecter, ni la preuve qu'une demande a
|
||||
// été servie ; rouvert, il le reste (marque persistée), et la marque ne part
|
||||
// jamais au moteur.
|
||||
func TestRelaisPasPrisPourLaDemande(t *testing.T) {
|
||||
r := keptRelay(10)
|
||||
msgs := []Message{um("ancienne"), am("ok"), um("vraie demande"), atc("see_image"), tm("[ok]"), r}
|
||||
if p := compactPending(msgs, msgs[:3], 3); len(p) != 1 || msgText(p[0]) != "vraie demande" {
|
||||
t.Fatalf("demande réinjectée : %+v", p)
|
||||
}
|
||||
raw, _ := json.Marshal(r)
|
||||
var back Message
|
||||
if err := json.Unmarshal(raw, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back = stripImageParts([]Message{back})[0]
|
||||
if !isLokiInjected(back) {
|
||||
t.Fatal("relais rouvert pris pour une demande")
|
||||
}
|
||||
sent, _ := wireMessages(msgs, false, echoPolicy{})
|
||||
for _, m := range sent {
|
||||
if m.ImgRelay {
|
||||
t.Fatal("marque de relais dans la requête")
|
||||
}
|
||||
}
|
||||
if !msgs[5].ImgRelay {
|
||||
t.Fatal("l'historique a perdu sa marque à l'envoi")
|
||||
}
|
||||
}
|
||||
|
||||
// Réécriture en cours de tour : une image non gardée n'entre pas dans
|
||||
// l'historique publié ; une image gardée, ou déjà retirée, si.
|
||||
func TestKeepImagesPublication(t *testing.T) {
|
||||
k := &keepImages{}
|
||||
eph := Message{Role: "user", ImgRelay: true, Content: []map[string]any{
|
||||
{"type": "text", "text": "Image :"}, {"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,AA=="}}}}
|
||||
stub := Message{Role: "user", ImgRelay: true, Content: "Image :" + imageLostMarker}
|
||||
msgs := []Message{um("q"), tm("a"), eph, tm("b"), keptRelay(50), stub}
|
||||
got := k.publishable(msgs)
|
||||
if len(got) != 5 || got[2].Content != "b" {
|
||||
t.Fatalf("publié : %+v", got)
|
||||
}
|
||||
k.pend = []keepPend{{msgIdx: 2, extraIdx: 0, weight: 1}}
|
||||
k.rewritten()
|
||||
if !k.stopped || k.pend != nil {
|
||||
t.Fatal("images à l'essai encore suivies après une réécriture")
|
||||
}
|
||||
}
|
||||
|
||||
// Début de tour : clé retirée → images gardées retirées ; clé présente et
|
||||
// vision active sans besoin de compacter → gardées.
|
||||
func TestKeptImagesDebutDeTour(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
visionServer(t, sseStop)
|
||||
c := newTestConv()
|
||||
msgs := []Message{um("q"), atc("see_image"), tm("[ok]"), keptRelay(300), am("vu"), um("suite")}
|
||||
c.Messages = append([]Message(nil), msgs...)
|
||||
c.CtxUsed = 2000
|
||||
out, used := c.keptImagesTurnStart(c.epoch, msgs, 2000, 0)
|
||||
if hasKeptImages(out) || used != 1700 || hasKeptImages(c.Messages) || c.CtxUsed != 1700 {
|
||||
t.Fatalf("clé absente : images gardées encore là (used=%d)", used)
|
||||
}
|
||||
if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.Messages = append([]Message(nil), msgs...)
|
||||
out, used = c.keptImagesTurnStart(c.epoch, msgs, 2000, 0)
|
||||
if !hasKeptImages(out) || used != 2000 {
|
||||
t.Fatal("images retirées alors que rien ne le demandait")
|
||||
}
|
||||
// Contexte au seuil : retirées d'abord.
|
||||
out, used = c.keptImagesTurnStart(c.epoch, msgs, ctxWindow(), 0)
|
||||
if hasKeptImages(out) || used != ctxWindow()-300 {
|
||||
t.Fatal("images gardées malgré un contexte au seuil")
|
||||
}
|
||||
}
|
||||
|
||||
// NUDGE_IN_TOOL : seulement avec la clé, sur le moteur local, si la sonde a dit
|
||||
// « instable » pour CE modèle.
|
||||
func TestNudgeInToolOn(t *testing.T) {
|
||||
freshTplProbe(t)
|
||||
cfg := map[string]string{"MODEL": "/models/qwen35.gguf"}
|
||||
set := func(r tplProbeResult) {
|
||||
tplProbeMu.Lock()
|
||||
tplProbeLast = &r
|
||||
tplProbeMu.Unlock()
|
||||
}
|
||||
set(tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo})
|
||||
if nudgeInToolOn(cfg, chatEndpoint{}) {
|
||||
t.Fatal("actif sans la clé")
|
||||
}
|
||||
cfg["NUDGE_IN_TOOL"] = "on"
|
||||
if !nudgeInToolOn(cfg, chatEndpoint{}) {
|
||||
t.Fatal("inactif avec la clé sur un gabarit instable")
|
||||
}
|
||||
if nudgeInToolOn(cfg, chatEndpoint{External: true}) {
|
||||
t.Fatal("actif sur un preset externe")
|
||||
}
|
||||
for _, r := range []tplProbeResult{
|
||||
{Model: "qwen35.gguf", PrefixStable: tplUnknown},
|
||||
{Model: "qwen35.gguf", PrefixStable: tplYes},
|
||||
{Model: "autre.gguf", PrefixStable: tplNo},
|
||||
} {
|
||||
set(r)
|
||||
if nudgeInToolOn(cfg, chatEndpoint{}) {
|
||||
t.Fatalf("actif pour %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeIntoTool(t *testing.T) {
|
||||
msgs := []Message{um("q"), atc("glob"), tm("res")}
|
||||
extra := []Message{atc("glob"), tm("res")}
|
||||
m2, e2, ok := nudgeIntoTool(msgs, extra, "[system] stop")
|
||||
if !ok || m2[2].Content != "res\n\n[system] stop" || !reflect.DeepEqual(m2[2], e2[1]) {
|
||||
t.Fatalf("rappel mal posé : %+v / %+v", m2, e2)
|
||||
}
|
||||
if msgs[2].Content != "res" || extra[1].Content != "res" {
|
||||
t.Fatal("messages modifiés en place")
|
||||
}
|
||||
// Dernier message qui n'est pas un résultat d'outil, ou historique réécrit.
|
||||
if _, _, ok := nudgeIntoTool(append(msgs, um("img")), extra, "x"); ok {
|
||||
t.Fatal("rappel posé derrière un message user")
|
||||
}
|
||||
if _, _, ok := nudgeIntoTool(msgs, nil, "x"); ok {
|
||||
t.Fatal("rappel posé sans le résultat dans l'historique")
|
||||
}
|
||||
}
|
||||
|
||||
// Bout à bout : avec la clé et un gabarit instable, le rappel part au bout du
|
||||
// résultat d'outil, persisté tel qu'envoyé, sans message user à part.
|
||||
func TestRappelDeBudgetDansLeResultat(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
freshTplProbe(t)
|
||||
for k, v := range map[string]string{"AGENT_BUDGET": "1", "NUDGE_IN_TOOL": "on", "MODEL": "/models/qwen35.gguf"} {
|
||||
if err := SetConfigKey(k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tplProbeMu.Lock()
|
||||
tplProbeLast = &tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo}
|
||||
tplProbeMu.Unlock()
|
||||
reqs := scriptedServer(t, sseGlobCall, sseChunk("fini")+sseStop)
|
||||
extra, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := reqMessages(t, reqs()[1])
|
||||
last := sent[len(sent)-1]
|
||||
if last.Role != "tool" || !strings.HasSuffix(msgText(last), "unless exactly one specific call is genuinely still missing.") {
|
||||
t.Fatalf("rappel absent du résultat d'outil : %+v", last)
|
||||
}
|
||||
if len(extra) != 2 || !reflect.DeepEqual(extra[1], last) {
|
||||
t.Fatalf("historique %+v : le résultat doit y être tel qu'envoyé", extra)
|
||||
}
|
||||
}
|
||||
|
||||
// En cours de tour, au seuil : les images gardées partent d'abord, et le
|
||||
// résumé n'est pas demandé si leur retrait suffit.
|
||||
func TestKeepImagesRetireesEnCoursDeTour(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
for k, v := range map[string]string{"KEEP_TURN_IMAGES": "on", "CTX": "2000"} {
|
||||
if err := SetConfigKey(k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
glob := `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"g1","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*.go\"}"}}]}}]}` + "\n\n" +
|
||||
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n" +
|
||||
`data: {"choices":[],"usage":{"prompt_tokens":1600,"completion_tokens":10}}` + "\n\ndata: [DONE]\n\n"
|
||||
reqs := visionServer(t, glob, sseAnswer("fini", 0))
|
||||
hist := []Message{um("q"), atc("see_image"), tm("[ok]"), keptRelay(1000), am("vu"), um("suite")}
|
||||
var published []Message
|
||||
extra, err := runChat(mainCtx(t), hist, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
|
||||
if ev.NewHistory != nil {
|
||||
published = ev.NewHistory
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(reqs()); n != 2 {
|
||||
t.Fatalf("%d requêtes : un résumé est parti alors que retirer l'image suffisait", n)
|
||||
}
|
||||
if published == nil || hasKeptImages(published) || len(published) != len(hist)+2 {
|
||||
t.Fatalf("historique publié : %+v", published)
|
||||
}
|
||||
if published[3].Content != "Voici la capture demandée."+imageLostMarker {
|
||||
t.Fatalf("relais publié : %+v", published[3])
|
||||
}
|
||||
if relay := reqMessages(t, reqs()[1])[3]; msgText(relay) != "Voici la capture demandée."+imageLostMarker {
|
||||
t.Fatalf("image encore envoyée après son retrait : %+v", relay)
|
||||
}
|
||||
if len(extra) != 0 {
|
||||
t.Fatalf("extra après publication : %+v", extra)
|
||||
}
|
||||
}
|
||||
|
||||
// Budget déjà plein : l'image suivante reste éphémère sans même être rangée.
|
||||
func TestKeepImagesBudgetPleinAvantRangement(t *testing.T) {
|
||||
testHome(t)
|
||||
k := &keepImages{}
|
||||
msgs := []Message{um("q"), keptRelay(keepImgBudget())}
|
||||
img := seeImageMessage("x.png", imageURLPart([]byte("png"), "image/png"))
|
||||
got, extra := k.relay(msgs, nil, img, 1)
|
||||
if len(got) != 3 || len(extra) != 0 || !k.stopped || len(k.pend) != 0 || !got[2].ImgRelay {
|
||||
t.Fatalf("relais au budget plein : %+v / %+v", got, k)
|
||||
}
|
||||
if ents, _ := os.ReadDir(chatImgDir()); len(ents) != 0 {
|
||||
t.Fatal("image rangée alors que le budget était plein")
|
||||
}
|
||||
}
|
||||
|
||||
// Corrections de relecture du lot 2 : prompt refusé pour débordement alors que
|
||||
// des images sont gardées — elles partent d'abord et la requête est rejouée
|
||||
// sans elles, sans résumé (avec perte) tant que ça suffit.
|
||||
func TestKeepImagesRetireesAvantLeFiletReactif(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
for k, v := range map[string]string{"KEEP_TURN_IMAGES": "on", "CTX": "2000"} {
|
||||
if err := SetConfigKey(k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var bodies []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/props" {
|
||||
_, _ = w.Write([]byte(`{"modalities":{"vision":true}}`))
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
mu.Lock()
|
||||
bodies = append(bodies, body)
|
||||
n := len(bodies)
|
||||
mu.Unlock()
|
||||
if n == 1 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":400,"message":"request (2500 tokens) exceeds the available context size (2000 tokens), try increasing it"}}`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(sseAnswer("fini", 0)))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
for k, v := range map[string]string{"PORT": u.Port(), "MMPROJ": "/models/mmproj.gguf"} {
|
||||
if err := SetConfigKey(k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
visionProbeMu.Lock()
|
||||
visionProbeAt = time.Time{}
|
||||
visionProbeMu.Unlock()
|
||||
hist := []Message{um("q"), atc("see_image"), tm("[ok]"), keptRelay(1000), am("vu"), um("suite")}
|
||||
var published []Message
|
||||
if _, err := runChat(mainCtx(t), hist, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
|
||||
if ev.NewHistory != nil {
|
||||
published = ev.NewHistory
|
||||
}
|
||||
return true
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(bodies) != 2 {
|
||||
t.Fatalf("%d requêtes : un résumé est parti alors que retirer l'image suffisait", len(bodies))
|
||||
}
|
||||
if published == nil || hasKeptImages(published) || len(published) != len(hist) {
|
||||
t.Fatalf("historique publié : %+v", published)
|
||||
}
|
||||
if relay := reqMessages(t, bodies[1])[3]; msgText(relay) != "Voici la capture demandée."+imageLostMarker {
|
||||
t.Fatalf("image encore envoyée après son retrait : %+v", relay)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// chat_mem_pinned.go — RAPPEL des pages mémoire lues au compactage.
|
||||
//
|
||||
// Problème corrigé : quand le contexte est compacté, le torse (dont les résultats
|
||||
// de mem_read) est résumé. Une page mémoire qui portait, par exemple, TOUTES LES
|
||||
// RÈGLES à respecter pour une tâche était donc réduite à quelques mots dans le
|
||||
// résumé — et le modèle, après compactage, « oubliait » les règles et partait en
|
||||
// vrille.
|
||||
//
|
||||
// Choix retenu (le plus léger pour le contexte) : on ne garde RIEN verbatim. On
|
||||
// injecte simplement, en tête de la conversation compactée, un petit rappel qui
|
||||
// LISTE les pages que le modèle a lues (mem_read) et l'invite à en relire une si
|
||||
// elle est pertinente, pour être sûr d'avoir toutes les infos avant de répondre. Le
|
||||
// rappel ne pèse que les NOMS des pages (borné), le contenu complet reste à un
|
||||
// mem_read de distance. Le comportement de compactage est par ailleurs inchangé.
|
||||
//
|
||||
// Le rappel ACCUMULE les noms à travers les compactages successifs : le rappel du
|
||||
// compactage précédent est relu comme source, donc une page lue tôt (dont le
|
||||
// mem_read d'origine a depuis été résumé) reste listée.
|
||||
|
||||
// memReminderPrefix ouvre le message de rappel. Sert aussi à le reconnaître (pour le
|
||||
// relire comme source au compactage suivant, et pour le retirer avant reconstruction).
|
||||
const memReminderPrefix = "[MEMORY PAGES REMINDER]"
|
||||
|
||||
// memReminderMaxNames borne le nombre de noms listés : au-delà, une conversation qui
|
||||
// lit énormément de pages ne ferait pas enfler le rappel. On garde les plus RÉCENTES
|
||||
// (les plus susceptibles de concerner la tâche en cours).
|
||||
const memReminderMaxNames = 24
|
||||
|
||||
// collectReadPageNames rassemble les noms des pages mémoire LUES au fil de la
|
||||
// conversation, dans l'ordre (première lecture d'abord). Deux sources, toutes deux
|
||||
// balayées pour survivre aux compactages successifs :
|
||||
// - les résultats de mem_read encore présents (résultat `tool` relié à sa page via
|
||||
// l'argument `file` du tool_call correspondant), en ignorant les lectures ratées ;
|
||||
// - le rappel injecté à un compactage PRÉCÉDENT (une fois le mem_read d'origine
|
||||
// résumé, c'est la seule trace restante des noms).
|
||||
func collectReadPageNames(msgs []Message) []string {
|
||||
seen := map[string]bool{}
|
||||
var order []string
|
||||
add := func(name string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || seen[name] {
|
||||
return
|
||||
}
|
||||
seen[name] = true
|
||||
order = append(order, name)
|
||||
}
|
||||
// tool_call_id → nom de page pour les appels mem_read (le résultat `tool` ne
|
||||
// porte que l'id ; le nom vit dans l'argument `file` de l'appel de l'assistant).
|
||||
readCall := map[string]string{}
|
||||
for _, m := range msgs {
|
||||
for _, tc := range m.ToolCalls {
|
||||
if tc.Function.Name != "mem_read" || tc.ID == "" {
|
||||
continue
|
||||
}
|
||||
var a map[string]any
|
||||
if json.Unmarshal([]byte(tc.Function.Arguments), &a) == nil {
|
||||
if f, _ := a["file"].(string); f != "" {
|
||||
readCall[tc.ID] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range msgs {
|
||||
switch m.Role {
|
||||
case "system":
|
||||
for _, n := range parseReminderNames(msgText(m)) {
|
||||
add(n)
|
||||
}
|
||||
case "tool":
|
||||
name, ok := readCall[m.ToolCallID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(msgText(m)), "[erreur]") {
|
||||
continue // lecture ratée : rien à rappeler
|
||||
}
|
||||
add(name)
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// parseReminderNames relit la liste de noms d'un message de rappel produit par
|
||||
// buildReminderMessage. Renvoie nil si ce n'est pas un rappel.
|
||||
func parseReminderNames(text string) []string {
|
||||
if !strings.HasPrefix(text, memReminderPrefix) {
|
||||
return nil
|
||||
}
|
||||
// Format : 1re ligne `[MEMORY PAGES REMINDER] … read earlier: a.md, b.md, c.md`
|
||||
// (les noms après le dernier `: `, sans ponctuation finale) ; la consigne de
|
||||
// relecture est sur la ou les lignes SUIVANTES, donc hors de la liste.
|
||||
line := text
|
||||
if i := strings.IndexByte(line, '\n'); i >= 0 {
|
||||
line = line[:i]
|
||||
}
|
||||
i := strings.LastIndex(line, ": ")
|
||||
if i < 0 {
|
||||
return nil
|
||||
}
|
||||
list := line[i+2:]
|
||||
var out []string
|
||||
for _, part := range strings.Split(list, ",") {
|
||||
if n := strings.TrimSpace(part); n != "" {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildReminderMessage construit le message `system` de rappel. Renvoie ok=false si
|
||||
// aucune page n'a été lue. Borné à memReminderMaxNames (on garde les plus récentes).
|
||||
func buildReminderMessage(names []string) (Message, bool) {
|
||||
if len(names) == 0 {
|
||||
return Message{}, false
|
||||
}
|
||||
if len(names) > memReminderMaxNames {
|
||||
names = names[len(names)-memReminderMaxNames:] // les plus récentes
|
||||
}
|
||||
content := fmt.Sprintf("%s Memory pages you read earlier: %s\nTheir full content is no longer inline after context compaction — if any of them is relevant to what you're doing, mem_read it again so you have all the rules and info you need before answering.",
|
||||
memReminderPrefix, strings.Join(names, ", "))
|
||||
return Message{Role: "system", Content: content}, true
|
||||
}
|
||||
|
||||
// stripReminder retire le(s) message(s) de rappel d'une séquence (avant reconstruction).
|
||||
func stripReminder(msgs []Message) []Message {
|
||||
out := make([]Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if m.Role == "system" {
|
||||
if s, ok := m.Content.(string); ok && strings.HasPrefix(s, memReminderPrefix) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// remindReadMemPages reconstruit, à partir de l'historique AVANT compactage (source),
|
||||
// le rappel des pages mémoire lues, et l'injecte en tête de la séquence COMPACTÉE. On
|
||||
// retire d'abord un éventuel rappel hérité (porté par compacted depuis la tête
|
||||
// protégée) pour repartir d'un rappel propre et à jour.
|
||||
//
|
||||
// Appelé uniquement en mode agent (les outils mem_* existent) et hors mémoire coupée.
|
||||
// Sans page lue, no-op.
|
||||
func remindReadMemPages(compacted, source []Message) []Message {
|
||||
if memMode() == MemOff {
|
||||
return compacted
|
||||
}
|
||||
names := collectReadPageNames(source)
|
||||
compacted = stripReminder(compacted)
|
||||
if m, ok := buildReminderMessage(names); ok {
|
||||
return append([]Message{m}, compacted...)
|
||||
}
|
||||
return compacted
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Une page mémoire lue (mem_read) doit être retrouvée par son nom (via l'argument
|
||||
// `file` du tool_call), puis listée dans un rappel relisable.
|
||||
func TestReminderRoundTrip(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "user", Content: "applique les règles de la page"},
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{
|
||||
ID: "call_1", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"regles-tache.md"}`},
|
||||
}}},
|
||||
{Role: "tool", ToolCallID: "call_1", Content: "RÈGLE 1 : toujours vouvoyer."},
|
||||
}
|
||||
names := collectReadPageNames(msgs)
|
||||
if len(names) != 1 || names[0] != "regles-tache.md" {
|
||||
t.Fatalf("nom de page mal collecté : %v", names)
|
||||
}
|
||||
m, ok := buildReminderMessage(names)
|
||||
if !ok {
|
||||
t.Fatalf("un rappel aurait dû être construit")
|
||||
}
|
||||
txt, _ := m.Content.(string)
|
||||
if !strings.Contains(txt, "regles-tache.md") || !strings.Contains(txt, "mem_read") {
|
||||
t.Fatalf("le rappel ne cite pas la page ou n'invite pas à relire : %q", txt)
|
||||
}
|
||||
// Le rappel doit se relire (noms) pour survivre au compactage suivant.
|
||||
back := parseReminderNames(txt)
|
||||
if len(back) != 1 || back[0] != "regles-tache.md" {
|
||||
t.Fatalf("aller-retour du rappel cassé : %v", back)
|
||||
}
|
||||
}
|
||||
|
||||
// Le rappel doit ACCUMULER : un nom listé à un compactage précédent (dont le mem_read
|
||||
// d'origine a été résumé/effacé) est retrouvé via le rappel système, même sans le
|
||||
// résultat d'outil correspondant.
|
||||
func TestReminderAccumulatesAcrossCompactions(t *testing.T) {
|
||||
old, _ := buildReminderMessage([]string{"regles.md"})
|
||||
msgs := []Message{
|
||||
old, // rappel hérité (system)
|
||||
{Role: "user", Content: "[CONTEXT COMPACTED] résumé…"},
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{
|
||||
ID: "c2", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"procedure.md"}`},
|
||||
}}},
|
||||
{Role: "tool", ToolCallID: "c2", Content: "procédure détaillée"},
|
||||
}
|
||||
names := collectReadPageNames(msgs)
|
||||
joined := strings.Join(names, ",")
|
||||
if !strings.Contains(joined, "regles.md") {
|
||||
t.Fatalf("la page héritée a été perdue : %v", names)
|
||||
}
|
||||
if !strings.Contains(joined, "procedure.md") {
|
||||
t.Fatalf("la nouvelle page lue n'a pas été captée : %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Une même page lue plusieurs fois n'est listée qu'une fois.
|
||||
func TestReminderDedupes(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{ID: "a", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"p.md"}`}}}},
|
||||
{Role: "tool", ToolCallID: "a", Content: "v1"},
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{ID: "b", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"p.md"}`}}}},
|
||||
{Role: "tool", ToolCallID: "b", Content: "v2"},
|
||||
}
|
||||
if names := collectReadPageNames(msgs); len(names) != 1 || names[0] != "p.md" {
|
||||
t.Fatalf("attendu une seule occurrence de p.md, obtenu %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Une lecture ratée ([erreur] …) ne doit pas être rappelée.
|
||||
func TestReminderSkipsErrors(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "assistant", ToolCalls: []ToolCall{{ID: "e", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"absente.md"}`}}}},
|
||||
{Role: "tool", ToolCallID: "e", Content: "[erreur] page introuvable"},
|
||||
}
|
||||
if names := collectReadPageNames(msgs); len(names) != 0 {
|
||||
t.Fatalf("une lecture ratée ne doit pas être rappelée, obtenu %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// La liste de noms est bornée : au-delà du plafond, on garde les plus récentes.
|
||||
func TestReminderCapsNames(t *testing.T) {
|
||||
var names []string
|
||||
for i := 0; i < memReminderMaxNames+10; i++ {
|
||||
names = append(names, "page-"+string(rune('a'+i%26))+string(rune('0'+i/26))+".md")
|
||||
}
|
||||
m, ok := buildReminderMessage(names)
|
||||
if !ok {
|
||||
t.Fatalf("rappel attendu")
|
||||
}
|
||||
got := parseReminderNames(m.Content.(string))
|
||||
if len(got) != memReminderMaxNames {
|
||||
t.Fatalf("attendu %d noms max, obtenu %d", memReminderMaxNames, len(got))
|
||||
}
|
||||
// La toute dernière (plus récente) doit être conservée.
|
||||
if got[len(got)-1] != names[len(names)-1] {
|
||||
t.Fatalf("la page la plus récente doit être gardée : %q vs %q", got[len(got)-1], names[len(names)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// stripReminder retire le rappel sans toucher au reste.
|
||||
func TestStripReminder(t *testing.T) {
|
||||
m, _ := buildReminderMessage([]string{"p.md"})
|
||||
msgs := []Message{
|
||||
m,
|
||||
{Role: "system", Content: "Project context — autre chose"},
|
||||
{Role: "user", Content: "salut"},
|
||||
}
|
||||
out := stripReminder(msgs)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("attendu 2 messages après strip, obtenu %d", len(out))
|
||||
}
|
||||
for _, mm := range out {
|
||||
if s, _ := mm.Content.(string); strings.HasPrefix(s, memReminderPrefix) {
|
||||
t.Fatalf("le rappel a survécu au strip")
|
||||
}
|
||||
}
|
||||
}
|
||||
+171
-68
@@ -3,6 +3,7 @@ package loki
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -88,8 +89,15 @@ func MemList() []MemPage {
|
||||
if !strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
|
||||
continue
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(memoryDir(), e.Name()))
|
||||
out = append(out, MemPage{Name: e.Name(), Title: titleOf(string(b))})
|
||||
// Page chiffrée + mémoire verrouillée : on la LISTE quand même (elle
|
||||
// existe, la cacher ferait croire à une perte) avec un titre qui dit
|
||||
// pourquoi son contenu n'apparaît pas.
|
||||
txt, ok := memPageText(e.Name())
|
||||
title := titleOf(txt)
|
||||
if !ok {
|
||||
title = "🔒 (chiffré — mémoire verrouillée)"
|
||||
}
|
||||
out = append(out, MemPage{Name: e.Name(), Title: title})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
@@ -98,36 +106,92 @@ func MemList() []MemPage {
|
||||
// MemSearch cherche les termes de la requête dans le nom + le contenu de chaque
|
||||
// page, renvoie une liste classée {fichier, titre, extrait}. Comme un moteur de
|
||||
// recherche : à compléter par mem_read sur la page la plus pertinente.
|
||||
//
|
||||
// Classement en deux temps, pour ne PAS laisser une grosse page bourrée d'un mot
|
||||
// fréquent écraser une petite page qui matche VRAIMENT la requête :
|
||||
// 1. Couverture d'abord : les pages qui contiennent le PLUS de termes distincts
|
||||
// de la requête passent devant (chercher « copine Nathan » doit remonter la
|
||||
// page qui a les deux mots, pas celle qui répète « Nathan » 40 fois).
|
||||
// 2. À couverture égale, score pondéré par la RARETÉ du terme (IDF) : un mot
|
||||
// rare et discriminant (« copine ») pèse bien plus qu'un mot omniprésent
|
||||
// (« Nathan »). La fréquence est amortie (log) pour que le bourrage ne gagne
|
||||
// pas, et un match dans le NOM ou le TITRE est fortement bonifié.
|
||||
func MemSearch(query string, limit int) []MemHit {
|
||||
if limit <= 0 || limit > 30 {
|
||||
limit = 8
|
||||
}
|
||||
terms := strings.Fields(strings.ToLower(query))
|
||||
type scored struct {
|
||||
hit MemHit
|
||||
score int
|
||||
terms := uniqueTerms(strings.ToLower(query))
|
||||
|
||||
type doc struct {
|
||||
p MemPage
|
||||
name string // minuscules
|
||||
title string // minuscules
|
||||
content string // brut (pour l'extrait)
|
||||
hay string // minuscules : nom + contenu
|
||||
}
|
||||
var ranked []scored
|
||||
docs := make([]doc, 0)
|
||||
df := make(map[string]int) // nb de pages contenant chaque terme
|
||||
for _, p := range MemList() {
|
||||
b, _ := os.ReadFile(filepath.Join(memoryDir(), p.Name))
|
||||
content := string(b)
|
||||
hay := strings.ToLower(p.Name + "\n" + content)
|
||||
score := 0
|
||||
content, _ := memPageText(p.Name) // "" si chiffré et verrouillé : matche alors sur le nom seul
|
||||
d := doc{
|
||||
p: p,
|
||||
name: strings.ToLower(p.Name),
|
||||
title: strings.ToLower(p.Title),
|
||||
content: content,
|
||||
hay: strings.ToLower(p.Name + "\n" + content),
|
||||
}
|
||||
docs = append(docs, d)
|
||||
for _, t := range terms {
|
||||
score += strings.Count(hay, t)
|
||||
if strings.Contains(strings.ToLower(p.Name), t) {
|
||||
score += 3 // bonus si le terme est dans le nom de la page
|
||||
if strings.Contains(d.hay, t) {
|
||||
df[t]++
|
||||
}
|
||||
}
|
||||
if score == 0 && query != "" {
|
||||
}
|
||||
n := len(docs)
|
||||
|
||||
type scored struct {
|
||||
hit MemHit
|
||||
matched int // termes distincts trouvés (clé de tri primaire)
|
||||
score float64 // pertinence pondérée (clé secondaire)
|
||||
}
|
||||
var ranked []scored
|
||||
for _, d := range docs {
|
||||
matched := 0
|
||||
score := 0.0
|
||||
for _, t := range terms {
|
||||
cnt := strings.Count(d.hay, t)
|
||||
if cnt == 0 {
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
// IDF : rare = discriminant. +1 pour qu'un terme fréquent compte encore.
|
||||
idf := math.Log(float64(n+1)/float64(df[t]+1)) + 1
|
||||
// Fréquence amortie : 10 occurrences ne valent pas 10x une occurrence.
|
||||
tf := 1.0 + math.Log(float64(cnt))
|
||||
field := 1.0
|
||||
if strings.Contains(d.name, t) {
|
||||
field += 4 // le terme est dans le NOM de la page
|
||||
}
|
||||
if strings.Contains(d.title, t) {
|
||||
field += 2 // ... ou dans son TITRE
|
||||
}
|
||||
score += idf * tf * field
|
||||
}
|
||||
if matched == 0 && len(terms) > 0 {
|
||||
continue
|
||||
}
|
||||
ranked = append(ranked, scored{
|
||||
hit: MemHit{File: p.Name, Title: p.Title, Snippet: snippetAround(content, terms)},
|
||||
score: score,
|
||||
hit: MemHit{File: d.p.Name, Title: d.p.Title, Snippet: snippetAround(d.content, terms)},
|
||||
matched: matched,
|
||||
score: score,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].score > ranked[j].score })
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
if ranked[i].matched != ranked[j].matched {
|
||||
return ranked[i].matched > ranked[j].matched // couverture d'abord
|
||||
}
|
||||
return ranked[i].score > ranked[j].score
|
||||
})
|
||||
out := []MemHit{}
|
||||
for i, r := range ranked {
|
||||
if i >= limit {
|
||||
@@ -138,6 +202,19 @@ func MemSearch(query string, limit int) []MemHit {
|
||||
return out
|
||||
}
|
||||
|
||||
// uniqueTerms découpe une requête en mots distincts (dédupliqués, ordre gardé).
|
||||
func uniqueTerms(q string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, t := range strings.Fields(q) {
|
||||
if !seen[t] {
|
||||
seen[t] = true
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// snippetAround renvoie un court extrait autour de la 1re occurrence d'un terme.
|
||||
func snippetAround(content string, terms []string) string {
|
||||
flat := strings.Join(strings.Fields(content), " ")
|
||||
@@ -175,12 +252,11 @@ func snippetAround(content string, terms []string) string {
|
||||
// MemRead lit une plage de lignes d'une page (1-indexé, lignes préfixées du
|
||||
// numéro). offset/limit par défaut : tout depuis la ligne 1 (cap 500).
|
||||
func MemRead(name string, offset, limit int) (string, error) {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, errMemLocked) {
|
||||
return "", fmt.Errorf("page '%s' chiffrée — mémoire verrouillée", name)
|
||||
}
|
||||
return "", fmt.Errorf("page '%s' introuvable", name)
|
||||
}
|
||||
lines := strings.Split(string(b), "\n")
|
||||
@@ -207,11 +283,14 @@ func MemAdd(name, content string) error {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return fmt.Errorf("la page existe déjà — utilise mem_edit pour la modifier")
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
body := strings.TrimRight(content, "\n") + "\n"
|
||||
if err := writeMemFile(name, []byte(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.TrimRight(content, "\n") + "\n"
|
||||
return os.WriteFile(p, []byte(body), 0o644)
|
||||
// L'index MEMORY.md est tenu par le CODE (mem_index.go) : une page créée y
|
||||
// apparaît toujours, même si le modèle oublie de l'y écrire.
|
||||
memIndexAdd(name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemEdit remplace oldText par newText dans une page. oldText doit apparaître
|
||||
@@ -221,12 +300,11 @@ func MemAdd(name, content string) error {
|
||||
var errAlreadyApplied = errors.New("déjà à jour — la page contient déjà cette modification")
|
||||
|
||||
func MemEdit(name, oldText, newText string) error {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, errMemLocked) {
|
||||
return fmt.Errorf("page '%s' chiffrée — mémoire verrouillée", name)
|
||||
}
|
||||
return fmt.Errorf("page '%s' introuvable", name)
|
||||
}
|
||||
content := string(b)
|
||||
@@ -246,17 +324,13 @@ func MemEdit(name, oldText, newText string) error {
|
||||
return fmt.Errorf("old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
|
||||
}
|
||||
updated := strings.Replace(content, oldText, newText, 1)
|
||||
return os.WriteFile(p, []byte(updated), 0o644)
|
||||
return writeMemFile(name, []byte(updated))
|
||||
}
|
||||
|
||||
// MemContent renvoie le contenu brut d'une page (vide si absente). Utilisé par
|
||||
// l'éditeur web.
|
||||
func MemContent(name string) string {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -276,15 +350,14 @@ func MemSave(name, old, content string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.TrimRight(content, "\n") + "\n"
|
||||
return os.WriteFile(p, []byte(body), 0o644)
|
||||
if err := writeMemFile(name, []byte(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Renommage depuis l'éditeur web : l'index suit (ancienne ligne retirée,
|
||||
// nouvelle ajoutée). Une simple sauvegarde n'ajoute que si la ligne manquait.
|
||||
memIndexRename(old, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemDelete supprime une page mémoire.
|
||||
@@ -296,51 +369,77 @@ func MemDelete(name string) error {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
return fmt.Errorf("introuvable")
|
||||
}
|
||||
return os.Remove(p)
|
||||
if err := os.Remove(p); err != nil {
|
||||
return err
|
||||
}
|
||||
memIndexRemove(name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du
|
||||
// mode agent (shell). Trois modes :
|
||||
// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne).
|
||||
// - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si
|
||||
// mode agent (shell). Réglé PAR PROJET (voir projectMemMode). Quatre modes :
|
||||
// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne).
|
||||
// - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si
|
||||
// l'utilisateur le demande explicitement (pas de recherche/écriture spontanée).
|
||||
// - MemAlways : comportement proactif historique (cherche avant de répondre, sauve d'elle-même).
|
||||
// - MemAlways : proactif, INDEX INJECTÉ — l'index MEMORY.md (titres +
|
||||
// accroches) est en tête de conversation, l'IA lit directement la bonne page.
|
||||
// - MemSearchFirst : proactif, RECHERCHE D'ABORD — rien n'est injecté, l'IA
|
||||
// appelle mem_search avant toute tâche. Prompt léger, démarrage plus rapide.
|
||||
type MemMode string
|
||||
|
||||
const (
|
||||
MemOff MemMode = "off"
|
||||
MemOnDemand MemMode = "ondemand"
|
||||
MemAlways MemMode = "always"
|
||||
MemOff MemMode = "off"
|
||||
MemOnDemand MemMode = "ondemand"
|
||||
MemAlways MemMode = "always"
|
||||
MemSearchFirst MemMode = "search"
|
||||
)
|
||||
|
||||
// memMode lit MEM_MODE dans config.env. Défaut = always (préserve le comportement
|
||||
// actuel). Toute valeur inconnue retombe sur always.
|
||||
func memMode() MemMode {
|
||||
switch strings.ToLower(strings.TrimSpace(ReadConfig()["MEM_MODE"])) {
|
||||
// memProactive : l'IA gère sa mémoire d'elle-même (cherche/sauve sans qu'on le
|
||||
// demande). Vrai pour les deux modes auto (injecté ou recherche).
|
||||
func memProactive(m MemMode) bool { return m == MemAlways || m == MemSearchFirst }
|
||||
|
||||
// normalizeMemMode ramène une valeur de config à un mode connu. Défaut = always
|
||||
// (préserve le comportement historique). "auto" est un alias d'always.
|
||||
func normalizeMemMode(v string) MemMode {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "off":
|
||||
return MemOff
|
||||
case "ondemand":
|
||||
return MemOnDemand
|
||||
case "search":
|
||||
return MemSearchFirst
|
||||
default: // "always", "auto", "" et inconnus
|
||||
return MemAlways
|
||||
}
|
||||
}
|
||||
|
||||
// setMemMode persiste le mode mémoire dans config.env.
|
||||
func setMemMode(m MemMode) error {
|
||||
return SetConfigKey("MEM_MODE", string(m))
|
||||
// memMode renvoie le mode mémoire du PROJET ACTIF (AJEAN 0.13.12). Repli : un
|
||||
// projet sans réglage propre (tous ceux d'avant) retombe sur l'ancien réglage
|
||||
// global MEM_MODE de config.env — always par défaut. Rien ne change donc pour
|
||||
// les projets existants tant qu'on ne touche pas à leur mode.
|
||||
func memMode() MemMode {
|
||||
if pm := projectMemMode(activeProjectSlug()); pm != "" {
|
||||
return normalizeMemMode(pm)
|
||||
}
|
||||
return normalizeMemMode(ReadConfig()["MEM_MODE"])
|
||||
}
|
||||
|
||||
// cmdMemory : loki memory [off|ondemand|always|status]
|
||||
// setMemMode persiste le mode mémoire du PROJET ACTIF.
|
||||
func setMemMode(m MemMode) error {
|
||||
return setProjectMemMode(activeProjectSlug(), string(m))
|
||||
}
|
||||
|
||||
// cmdMemory : loki memory [off|ondemand|always|search|status]
|
||||
func cmdMemory(args []string) error {
|
||||
sub := ""
|
||||
if len(args) > 0 {
|
||||
sub = strings.ToLower(strings.TrimSpace(args[0]))
|
||||
}
|
||||
label := map[MemMode]string{
|
||||
MemOff: "désactivée (l'IA n'a aucun accès mémoire)",
|
||||
MemOnDemand: "sur demande (outils dispo, utilisés seulement si tu le demandes)",
|
||||
MemAlways: "auto (l'IA cherche et sauve d'elle-même)",
|
||||
MemOff: "désactivée (l'IA n'a aucun accès mémoire)",
|
||||
MemOnDemand: "sur demande (outils dispo, utilisés seulement si tu le demandes)",
|
||||
MemAlways: "auto/injectée (index en tête de conversation, l'IA lit directement la bonne page)",
|
||||
MemSearchFirst: "auto/recherche (rien d'injecté, l'IA cherche avant toute tâche — prompt léger)",
|
||||
}
|
||||
switch sub {
|
||||
case "off":
|
||||
@@ -351,20 +450,24 @@ func cmdMemory(args []string) error {
|
||||
if err := setMemMode(MemOnDemand); err != nil {
|
||||
return err
|
||||
}
|
||||
case "always":
|
||||
case "always", "auto", "inject", "injected":
|
||||
if err := setMemMode(MemAlways); err != nil {
|
||||
return err
|
||||
}
|
||||
case "search", "recherche", "lazy":
|
||||
if err := setMemMode(MemSearchFirst); err != nil {
|
||||
return err
|
||||
}
|
||||
case "", "status":
|
||||
m := memMode()
|
||||
fmt.Printf("%s mode: %s — %s\n", cyan("Mémoire"), bold(string(m)), label[m])
|
||||
fmt.Printf("%s projet %s, mode: %s — %s\n", cyan("Mémoire"), bold(projectName(activeProjectSlug())), bold(string(m)), label[m])
|
||||
pages := MemList()
|
||||
fmt.Printf(" %d page(s) sous %s\n", len(pages), memoryDir())
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("usage: loki memory [off|ondemand|always|status]")
|
||||
return fmt.Errorf("usage: loki memory [off|ondemand|always|search|status]")
|
||||
}
|
||||
m := memMode()
|
||||
fmt.Printf("%s mémoire : %s — %s\n", green("[ok]"), bold(string(m)), label[m])
|
||||
fmt.Printf("%s mémoire du projet %s : %s — %s\n", green("[ok]"), bold(projectName(activeProjectSlug())), bold(string(m)), label[m])
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Reproduit le bug rapporté : une petite page dont le NOM et le TITRE contiennent
|
||||
// « copine » ne remontait pas pour la requête « copine Nathan », noyée par de
|
||||
// grosses pages qui répètent « Nathan » des dizaines de fois. Le classement doit
|
||||
// mettre en tête la page qui matche le PLUS de termes distincts (couverture),
|
||||
// puis pondérer par la rareté.
|
||||
func TestMemSearchCoverageBeatsFrequency(t *testing.T) {
|
||||
testHome(t)
|
||||
|
||||
// Deux pages « bruyantes » bourrées de « Nathan », sans « copine ».
|
||||
if err := MemAdd("business-nathan.md", "# Business Nathan\n"+strings.Repeat("Nathan veut un business. ", 40)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MemAdd("guide-ajean.md", "# Guide AJEAN\n"+strings.Repeat("projet de Nathan. ", 30)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// La petite page cible : matche les DEUX termes de la requête.
|
||||
if err := MemAdd("profil-copine-nathan.md", "# Profil : Copine de Nathan\n- Prénom : Bao\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hits := MemSearch("copine Nathan", 8)
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("aucun résultat pour « copine Nathan »")
|
||||
}
|
||||
if hits[0].File != "profil-copine-nathan.md" {
|
||||
var names []string
|
||||
for _, h := range hits {
|
||||
names = append(names, h.File)
|
||||
}
|
||||
t.Fatalf("la page copine devrait être 1re (matche les 2 termes), obtenu ordre : %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Une requête mono-terme rare doit privilégier un match dans le nom/titre.
|
||||
func TestMemSearchNameTitleBoost(t *testing.T) {
|
||||
testHome(t)
|
||||
_ = MemAdd("notes-diverses.md", "# Notes\nUn jour Bao est passée dire bonjour, puis repartie.\n")
|
||||
_ = MemAdd("profil-copine.md", "# Profil : Copine\n- Prénom : Bao\n")
|
||||
hits := MemSearch("copine", 8)
|
||||
if len(hits) == 0 || hits[0].File != "profil-copine.md" {
|
||||
t.Fatalf("la page dont le nom/titre porte « copine » devrait être 1re, obtenu : %+v", hits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
package loki
|
||||
|
||||
// chat_persist.go — l'écriture de la discussion SORT du chemin du premier token.
|
||||
//
|
||||
// persist() faisait tout en ligne : marshal, puis putStoreBytes (ouverture de la
|
||||
// base, commit, fsync), puis convTouchMeta (relecture de l'index, second commit,
|
||||
// second fsync). Dans StartTurn, c'était AVANT `go c.generate` : 26-30 ms
|
||||
// mesurés sur un NVMe Windows, bien davantage sur le /mnt/user d'Unraid (FUSE)
|
||||
// ou un disque de parité — payés à chaque message avant que la requête ne parte
|
||||
// vers llama-server. Même facture au milieu d'un tour (vérification du mode
|
||||
// Code) et pour chaque résultat d'outil coupé (« voir plus »).
|
||||
//
|
||||
// Désormais :
|
||||
// - l'INSTANTANÉ reste synchrone et pris sous c.mu (images → références,
|
||||
// marshal, identifiant de la discussion) : rien ne peut le muter pendant
|
||||
// qu'on le prend, et il dit toujours à quelle discussion il appartient ;
|
||||
// - seule l'E/S bbolt part à un écrivain UNIQUE, qui fusionne ce qui s'est
|
||||
// accumulé (dernier instantané de chaque discussion + résultats d'outils) en
|
||||
// UNE transaction — un fsync au lieu de deux, ou de vingt sur un tour d'agent ;
|
||||
// - seuls les chemins chauds sont asynchrones (StartTurn, persistance en cours
|
||||
// de tour, résultats d'outils). Fin de tour, reset, bascules et suppression
|
||||
// restent synchrones : ils attendent que l'écrivain ait tout vidé, eux
|
||||
// compris. Aucun n'est sur le chemin du premier token, et un redémarrage
|
||||
// juste après eux ne doit rien perdre.
|
||||
//
|
||||
// Ce qui atteint le modèle ne change pas d'un octet : il lit c.Messages en
|
||||
// mémoire, et les résultats complets des outils ne servent qu'au « voir plus »
|
||||
// de l'UI. Le risque accepté : un crash dans les millisecondes qui suivent un
|
||||
// envoi peut perdre le message tout juste envoyé — le même que celui d'un crash
|
||||
// en pleine génération, avant ce changement, pour la réponse.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// convSnap est un instantané complet d'une discussion, prêt à écrire.
|
||||
type convSnap struct {
|
||||
path string // base visée, figée à la prise (voir withDBAt)
|
||||
id string // discussion à laquelle appartient le contenu
|
||||
seq uint64 // ordre de prise : le plus grand l'emporte toujours
|
||||
body []byte // JSON de Conversation
|
||||
title string // titre déduit du premier message (convSummary)
|
||||
turns int
|
||||
project string // projet d'une entrée d'index à créer (voir convTouchMetaIn)
|
||||
}
|
||||
|
||||
// toolResJob est un résultat d'outil complet à ranger dans bkToolRes.
|
||||
type toolResJob struct {
|
||||
path string
|
||||
key string
|
||||
plain string
|
||||
}
|
||||
|
||||
// convPersister sérialise TOUTES les écritures de discussion. Un seul écrivain à
|
||||
// la fois : l'ordre des instantanés ne dépend donc que de seq, jamais d'une
|
||||
// course entre deux goroutines d'écriture.
|
||||
type convPersister struct {
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
snaps map[string]*convSnap // dernier instantané en attente, par discussion
|
||||
res []toolResJob // résultats d'outils en attente, dans l'ordre
|
||||
mem map[string]string // résultats pas encore écrits : « voir plus » les sert d'ici
|
||||
written map[string]uint64 // seq du dernier instantané écrit, par discussion
|
||||
deleted map[string]bool // discussions supprimées : plus rien ne doit les réécrire
|
||||
queued uint64 // tickets délivrés
|
||||
done uint64 // tickets traités (écrits, abandonnés ou en échec)
|
||||
running bool
|
||||
|
||||
// Résultats d'outils du lot en cours d'écriture, et ceux d'entre eux qu'une
|
||||
// suppression a annulés depuis : l'écrivain les saute dans sa transaction.
|
||||
inflight map[string]bool
|
||||
cancelled map[string]bool
|
||||
}
|
||||
|
||||
var (
|
||||
persistQ = newConvPersister()
|
||||
// persistSeq numérote les instantanés. Incrémenté SOUS c.mu : l'ordre des
|
||||
// numéros est donc celui des états de la discussion.
|
||||
persistSeq atomic.Uint64
|
||||
// persistCommit écrit un lot ; remplaçable par les tests pour observer
|
||||
// l'ordre des écritures ou les ralentir.
|
||||
persistCommit = commitPersistBatch
|
||||
)
|
||||
|
||||
func newConvPersister() *convPersister {
|
||||
p := &convPersister{
|
||||
snaps: map[string]*convSnap{},
|
||||
mem: map[string]string{},
|
||||
written: map[string]uint64{},
|
||||
deleted: map[string]bool{},
|
||||
|
||||
inflight: map[string]bool{},
|
||||
cancelled: map[string]bool{},
|
||||
}
|
||||
p.cond = sync.NewCond(&p.mu)
|
||||
return p
|
||||
}
|
||||
|
||||
// enqueueSnap confie un instantané à l'écrivain et renvoie son ticket. Un
|
||||
// instantané en attente pour la même discussion est remplacé par le plus récent :
|
||||
// écrire l'ancien ne servirait qu'à l'écraser aussitôt.
|
||||
func (p *convPersister) enqueueSnap(s *convSnap) uint64 {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if !p.deleted[s.id] {
|
||||
if cur := p.snaps[s.id]; cur == nil || s.seq > cur.seq {
|
||||
p.snaps[s.id] = s
|
||||
}
|
||||
}
|
||||
return p.kickLocked()
|
||||
}
|
||||
|
||||
// enqueueToolRes confie un résultat d'outil à l'écrivain. Il est servi depuis
|
||||
// la mémoire tant qu'il n'est pas sur disque.
|
||||
func (p *convPersister) enqueueToolRes(j toolResJob) uint64 {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
// Discussion déjà supprimée (un outil finit juste après la suppression) :
|
||||
// l'écrivain l'écarterait, et la copie en mémoire ne partirait plus jamais.
|
||||
if !p.deleted[toolResConv(j.key)] {
|
||||
p.mem[j.key] = j.plain
|
||||
p.res = append(p.res, j)
|
||||
}
|
||||
return p.kickLocked()
|
||||
}
|
||||
|
||||
// kickLocked délivre un ticket et démarre l'écrivain s'il dort. p.mu détenu.
|
||||
func (p *convPersister) kickLocked() uint64 {
|
||||
p.queued++
|
||||
if !p.running {
|
||||
p.running = true
|
||||
go p.run()
|
||||
}
|
||||
return p.queued
|
||||
}
|
||||
|
||||
// run vide la file par lots, jusqu'à ce qu'elle soit vide.
|
||||
func (p *convPersister) run() {
|
||||
for {
|
||||
p.mu.Lock()
|
||||
if len(p.snaps) == 0 && len(p.res) == 0 {
|
||||
p.running = false
|
||||
p.done = p.queued
|
||||
p.cond.Broadcast()
|
||||
p.mu.Unlock()
|
||||
return
|
||||
}
|
||||
upto := p.queued
|
||||
snaps := make([]*convSnap, 0, len(p.snaps))
|
||||
for id, s := range p.snaps {
|
||||
if !p.deleted[id] && s.seq > p.written[id] {
|
||||
snaps = append(snaps, s)
|
||||
}
|
||||
}
|
||||
res := make([]toolResJob, 0, len(p.res))
|
||||
for _, j := range p.res {
|
||||
if !p.deleted[toolResConv(j.key)] {
|
||||
res = append(res, j)
|
||||
p.inflight[j.key] = true
|
||||
}
|
||||
}
|
||||
p.snaps, p.res = map[string]*convSnap{}, nil
|
||||
p.mu.Unlock()
|
||||
|
||||
sort.Slice(snaps, func(i, j int) bool { return snaps[i].seq < snaps[j].seq })
|
||||
wrote, err := persistCommitByPath(snaps, res)
|
||||
|
||||
p.mu.Lock()
|
||||
for _, j := range res {
|
||||
delete(p.inflight, j.key)
|
||||
delete(p.cancelled, j.key)
|
||||
}
|
||||
for _, s := range wrote {
|
||||
if s.seq > p.written[s.id] {
|
||||
p.written[s.id] = s.seq
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// Les résultats restent servis depuis la mémoire : le « voir plus » de
|
||||
// cette session marche encore, seul un redémarrage les perdrait. Un
|
||||
// instantané raté sera remplacé par le suivant (fin de tour au plus tard).
|
||||
fmt.Fprintf(os.Stderr, "[persist] écriture différée échouée (%d instantané(s), %d résultat(s) d'outil gardé(s) en mémoire) : %v\n", len(snaps)-len(wrote), len(res), err)
|
||||
} else {
|
||||
for _, j := range res {
|
||||
if p.mem[j.key] == j.plain {
|
||||
delete(p.mem, j.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.done = upto
|
||||
p.cond.Broadcast()
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// wait bloque jusqu'à ce que le ticket (et tous ceux d'avant) soit traité.
|
||||
func (p *convPersister) wait(ticket uint64) {
|
||||
p.mu.Lock()
|
||||
for p.done < ticket {
|
||||
p.cond.Wait()
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// flush attend que tout ce qui a été confié jusqu'ici soit traité.
|
||||
func (p *convPersister) flush() {
|
||||
p.mu.Lock()
|
||||
t := p.queued
|
||||
p.mu.Unlock()
|
||||
p.wait(t)
|
||||
}
|
||||
|
||||
// forget marque une discussion comme supprimée : ses instantanés et résultats en
|
||||
// attente sont jetés, et plus rien ne la réécrira — sans ça, un instantané en
|
||||
// vol la ferait renaître (contenu ET entrée d'index) juste après sa suppression.
|
||||
func (p *convPersister) forget(id string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.deleted[id] = true
|
||||
delete(p.snaps, id)
|
||||
p.dropToolResLocked(id + ".")
|
||||
}
|
||||
|
||||
// dropToolRes oublie les résultats en attente d'une discussion.
|
||||
func (p *convPersister) dropToolRes(prefix string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.dropToolResLocked(prefix)
|
||||
}
|
||||
|
||||
func (p *convPersister) dropToolResLocked(prefix string) {
|
||||
// Déjà pris par l'écrivain : trop tard pour le retirer de la file, pas pour
|
||||
// l'empêcher d'atteindre la base (voir toolResWanted).
|
||||
for k := range p.inflight {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
p.cancelled[k] = true
|
||||
}
|
||||
}
|
||||
for k := range p.mem {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
delete(p.mem, k)
|
||||
}
|
||||
}
|
||||
kept := p.res[:0]
|
||||
for _, j := range p.res {
|
||||
if !strings.HasPrefix(j.key, prefix) {
|
||||
kept = append(kept, j)
|
||||
}
|
||||
}
|
||||
p.res = kept
|
||||
}
|
||||
|
||||
// toolResWanted : ce résultat du lot en cours doit-il encore être écrit ?
|
||||
// Appelé par l'écrivain DANS sa transaction. Une suppression annule avant
|
||||
// d'ouvrir la sienne (deleteToolResultsFor) : soit l'écrivain voit
|
||||
// l'annulation et saute le résultat, soit il l'a déjà écrit et la suppression,
|
||||
// sérialisée après lui par bbolt, l'efface. Jamais de résultat qui renaît.
|
||||
func (p *convPersister) toolResWanted(key string) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return !p.cancelled[key]
|
||||
}
|
||||
|
||||
// toolResPending relit un résultat pas encore sur disque.
|
||||
func (p *convPersister) toolResPending(key string) (string, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
s, ok := p.mem[key]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// toolResConv extrait l'identifiant de discussion d'une clé « <id>.<aléa> ».
|
||||
func toolResConv(key string) string {
|
||||
if i := strings.LastIndexByte(key, '.'); i > 0 {
|
||||
return key[:i]
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// persistExitWait borne l'attente de l'écrivain avant une sortie : le délai
|
||||
// d'ouverture de la base (5 s, withDBAt) plus une marge.
|
||||
const persistExitWait = 6 * time.Second
|
||||
|
||||
// flushPersister attend que les écritures différées soient sur disque, au plus
|
||||
// d pour ne jamais retenir un arrêt sur une base bloquée. À appeler avant toute
|
||||
// sortie volontaire du process (redémarrage, « Quitter », exec).
|
||||
func flushPersister(d time.Duration) {
|
||||
ok := make(chan struct{})
|
||||
go func() {
|
||||
persistQ.flush()
|
||||
close(ok)
|
||||
}()
|
||||
select {
|
||||
case <-ok:
|
||||
case <-time.After(d):
|
||||
}
|
||||
}
|
||||
|
||||
// persistCommitByPath regroupe le lot par base (une seule en production) et
|
||||
// l'écrit. Renvoie les instantanés effectivement écrits.
|
||||
func persistCommitByPath(snaps []*convSnap, res []toolResJob) ([]*convSnap, error) {
|
||||
paths := map[string]bool{}
|
||||
for _, s := range snaps {
|
||||
paths[s.path] = true
|
||||
}
|
||||
for _, j := range res {
|
||||
paths[j.path] = true
|
||||
}
|
||||
var wrote []*convSnap
|
||||
var firstErr error
|
||||
for path := range paths {
|
||||
var ps []*convSnap
|
||||
var pr []toolResJob
|
||||
for _, s := range snaps {
|
||||
if s.path == path {
|
||||
ps = append(ps, s)
|
||||
}
|
||||
}
|
||||
for _, j := range res {
|
||||
if j.path == path {
|
||||
pr = append(pr, j)
|
||||
}
|
||||
}
|
||||
w, err := persistCommit(path, ps, pr)
|
||||
wrote = append(wrote, w...)
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return wrote, firstErr
|
||||
}
|
||||
|
||||
// commitPersistBatch écrit un lot en UNE transaction : les résultats d'outils,
|
||||
// puis chaque instantané avec la mise à jour de son entrée d'index. Avant, le
|
||||
// contenu et l'index étaient deux commits (deux fsync), et la relecture de
|
||||
// l'index hors de tout verrou pouvait écraser un renommage concurrent.
|
||||
//
|
||||
// Mémoire chiffrée mais verrouillée : rien n'est écrit en clair, comme
|
||||
// putStoreBytes — les instantanés sont sautés (le fil reste en RAM), les
|
||||
// résultats d'outils renvoient une erreur et restent servis depuis la mémoire.
|
||||
func commitPersistBatch(path string, snaps []*convSnap, res []toolResJob) ([]*convSnap, error) {
|
||||
// Hors transaction : memEncActive relit la configuration, donc rouvrirait la
|
||||
// base — bbolt n'est pas rentrant (voir memEncoderNow).
|
||||
encode, encErr := memEncoderNow()
|
||||
if len(res) > 0 && encErr != nil {
|
||||
return nil, encErr
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
convIndexMu.Lock()
|
||||
defer convIndexMu.Unlock()
|
||||
cacheBust(bkChat)
|
||||
cacheBust(bkToolRes)
|
||||
var wrote []*convSnap
|
||||
err := withDBAt(path, func(d *bolt.DB) error {
|
||||
return d.Update(func(tx *bolt.Tx) error {
|
||||
wrote = wrote[:0]
|
||||
if len(res) > 0 {
|
||||
b, err := tx.CreateBucketIfNotExists([]byte(bkToolRes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, j := range res {
|
||||
if !persistQ.toolResWanted(j.key) {
|
||||
continue // supprimé avec sa discussion pendant que le lot partait
|
||||
}
|
||||
enc, err := encode([]byte(j.plain))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.Put([]byte(j.key), enc); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(snaps) == 0 || encErr != nil {
|
||||
return nil
|
||||
}
|
||||
b, err := tx.CreateBucketIfNotExists([]byte(bkChat))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Index illisible (chiffré par une autre clé, abîmé) : on écrit le
|
||||
// contenu mais on ne touche PAS à l'index — le réécrire depuis une
|
||||
// lecture vide effacerait toutes les autres discussions de la liste.
|
||||
var idx []convMeta
|
||||
idxOK := true
|
||||
if raw := b.Get([]byte(ckIndex)); len(raw) > 0 {
|
||||
dec, err := decodeMemContent(raw)
|
||||
if err != nil || json.Unmarshal(dec, &idx) != nil {
|
||||
idxOK = false
|
||||
}
|
||||
}
|
||||
for _, s := range snaps {
|
||||
enc, err := encode(s.body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.Put([]byte(convKey(s.id)), enc); err != nil {
|
||||
return err
|
||||
}
|
||||
if idxOK {
|
||||
idx = convTouchMetaIn(idx, s, now)
|
||||
}
|
||||
wrote = append(wrote, s)
|
||||
}
|
||||
if !idxOK {
|
||||
return nil
|
||||
}
|
||||
ib, err := json.Marshal(idx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enc, err := encode(ib)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put([]byte(ckIndex), enc)
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wrote, nil
|
||||
}
|
||||
|
||||
// convTouchMetaIn rafraîchit l'entrée d'index d'un instantané : date, nombre
|
||||
// d'échanges, titre déduit si l'utilisateur n'en a pas choisi. Une discussion
|
||||
// absente de l'index y est ajoutée — c'est ce qui recolle une discussion créée
|
||||
// mémoire verrouillée (son entrée n'avait pas pu s'écrire). Une discussion
|
||||
// SUPPRIMÉE n'arrive jamais ici : l'écrivain l'a écartée (forget).
|
||||
func convTouchMetaIn(idx []convMeta, s *convSnap, now int64) []convMeta {
|
||||
for i := range idx {
|
||||
if idx[i].ID != s.id {
|
||||
continue
|
||||
}
|
||||
idx[i].Updated = now
|
||||
idx[i].Turns = s.turns
|
||||
if idx[i].Title == "" {
|
||||
idx[i].Title = s.title
|
||||
}
|
||||
return idx
|
||||
}
|
||||
return append(idx, convMeta{ID: s.id, Title: s.title, Created: now, Updated: now, Turns: s.turns, Project: s.project})
|
||||
}
|
||||
|
||||
// --- Discussion active, gardée en RAM -----------------------------------------
|
||||
//
|
||||
// convEnsureActive relisait bkChat/active à CHAQUE appel : une ouverture de base
|
||||
// (3-4 ms sur Windows) pour le mode Code, l'espace de travail, les résultats
|
||||
// d'outils, la télémétrie… plusieurs fois par tour. Un seul process possède la
|
||||
// conversation (relay_link.go) et toutes les bascules passent par
|
||||
// convActivate : le cache ne peut donc pas diverger de la base, exactement
|
||||
// comme activeProjCache pour le projet actif.
|
||||
//
|
||||
// Le cache sert aussi de LIEN entre le contenu en mémoire et son identifiant :
|
||||
// convActivate le change sous c.mu, en même temps que le contenu (loadFrom). Un
|
||||
// instantané pris sous c.mu lit donc toujours l'identifiant du contenu qu'il
|
||||
// sérialise — jamais l'ancien fil sous le nouvel identifiant, même si une
|
||||
// bascule s'intercale entre deux étapes de persist.
|
||||
|
||||
type convActiveRef struct{ path, id string }
|
||||
|
||||
var (
|
||||
// convActiveMu sérialise la création de l'identifiant et les bascules.
|
||||
// Ordre des verrous : convActiveMu, puis c.mu (loadFrom). Jamais l'inverse —
|
||||
// d'où la lecture sans verrou du cache chaud.
|
||||
convActiveMu sync.Mutex
|
||||
convActiveCache atomic.Pointer[convActiveRef]
|
||||
// convIndexMu sérialise les lectures-modifications-écritures de l'index :
|
||||
// celles des opérations de discussion et celle de l'écrivain.
|
||||
convIndexMu sync.Mutex
|
||||
)
|
||||
|
||||
// convActiveCached renvoie l'identifiant en cache pour la base courante, ou "".
|
||||
func convActiveCached() string {
|
||||
if r := convActiveCache.Load(); r != nil && r.path == dbPath() {
|
||||
return r.id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// convActiveRemember met l'identifiant en cache. Un pointeur encore chiffré par
|
||||
// une ancienne version (healPlainStoreKeys) n'est pas mis en cache : il sera
|
||||
// relu en clair après la réparation.
|
||||
func convActiveRemember(id string) {
|
||||
if id == "" || looksEncrypted([]byte(id)) {
|
||||
convActiveCache.Store(nil)
|
||||
return
|
||||
}
|
||||
convActiveCache.Store(&convActiveRef{path: dbPath(), id: id})
|
||||
}
|
||||
|
||||
// convActiveID lit la discussion active SANS jamais en créer une : cache chaud,
|
||||
// sinon la clé en base (vide si aucune). Pour les lecteurs qui, avant le cache,
|
||||
// lisaient la clé brute — les résultats d'outils d'une tâche de fond sur une base
|
||||
// neuve vont sous « nosession » au lieu de forger une discussion vide.
|
||||
func convActiveID() string {
|
||||
if id := convActiveCached(); id != "" {
|
||||
return id
|
||||
}
|
||||
return getStr(bkChat, ckActive)
|
||||
}
|
||||
|
||||
// convActivate fait de id la discussion active et charge b en mémoire, sous
|
||||
// convActiveMu : le pointeur en base, le cache et le contenu changent ensemble.
|
||||
func convActivate(id string, b []byte) {
|
||||
convActiveMu.Lock()
|
||||
defer convActiveMu.Unlock()
|
||||
_ = putStr(bkChat, ckActive, id)
|
||||
conv.loadFrom(id, b)
|
||||
}
|
||||
|
||||
// snapshot prend l'instantané à écrire. L'appelant NE doit PAS détenir mu.
|
||||
func (c *Conversation) snapshot() *convSnap {
|
||||
// Hors verrou : au premier appel, convEnsureActive lit (voire crée) la clé
|
||||
// en base. Ensuite le cache est chaud et relu sous c.mu.
|
||||
id0 := convEnsureActive()
|
||||
project := activeProjectSlug()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
id := id0
|
||||
if c == conv {
|
||||
if cid := convActiveCached(); cid != "" {
|
||||
id = cid
|
||||
}
|
||||
}
|
||||
// Images en base64 → références (chat_images.go) avant d'écrire : une photo
|
||||
// jointe pesait des Mo, réécrits en entier à CHAQUE fin de tour.
|
||||
refImagesInMessages(c.Messages)
|
||||
// En plein tour, le journal porte encore un événement par token et chaque
|
||||
// frappe d'outil : on écrit la forme que produira la fin de tour
|
||||
// (compactLog), sans toucher au journal en mémoire que suivent les abonnés.
|
||||
full := c.Log
|
||||
if c.Generating {
|
||||
c.Log = compactLog(full)
|
||||
}
|
||||
b, err := json.Marshal(c)
|
||||
c.Log = full
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
turns := 0
|
||||
for _, ev := range full {
|
||||
if _, ok := ev.Delta["user"]; ok {
|
||||
turns++
|
||||
}
|
||||
}
|
||||
return &convSnap{
|
||||
path: dbPath(),
|
||||
id: id,
|
||||
seq: persistSeq.Add(1),
|
||||
body: b,
|
||||
title: convSummary(c.Messages),
|
||||
turns: turns,
|
||||
project: project,
|
||||
}
|
||||
}
|
||||
|
||||
// persist enregistre l'état sous sa discussion et ATTEND l'écriture : fin de
|
||||
// tour, reset, bascules, compactage manuel. Tout ce qui était en attente avant
|
||||
// lui est écrit aussi. L'appelant NE doit PAS détenir mu.
|
||||
func (c *Conversation) persist() {
|
||||
if s := c.snapshot(); s != nil {
|
||||
persistQ.wait(persistQ.enqueueSnap(s))
|
||||
return
|
||||
}
|
||||
persistQ.flush()
|
||||
}
|
||||
|
||||
// persistAsync prend l'instantané tout de suite mais laisse l'écriture à
|
||||
// l'écrivain : pour les chemins qui précèdent un appel au modèle (StartTurn,
|
||||
// persistance en cours de tour). L'identifiant de la discussion est résolu ici,
|
||||
// de façon synchrone — sur une base neuve, generate trouve donc la discussion
|
||||
// active déjà créée, comme avant. L'appelant NE doit PAS détenir mu.
|
||||
func (c *Conversation) persistAsync() {
|
||||
if s := c.snapshot(); s != nil {
|
||||
persistQ.enqueueSnap(s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// gatePersist remplace l'écriture par une version qu'on retient : chaque lot
|
||||
// signale son arrivée sur entered puis attend que release soit fermé. Les
|
||||
// échanges de persistCommit se font écrivain vidé : aucun lot ne le lit alors.
|
||||
func gatePersist(t *testing.T) (entered chan []*convSnap, release chan struct{}) {
|
||||
t.Helper()
|
||||
entered = make(chan []*convSnap, 64)
|
||||
release = make(chan struct{})
|
||||
persistQ.flush()
|
||||
prev := persistCommit
|
||||
persistCommit = func(path string, s []*convSnap, r []toolResJob) ([]*convSnap, error) {
|
||||
entered <- s
|
||||
<-release
|
||||
return prev(path, s, r)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
persistQ.flush()
|
||||
persistCommit = prev
|
||||
})
|
||||
return entered, release
|
||||
}
|
||||
|
||||
// waitEntered attend qu'un lot arrive à l'écriture.
|
||||
func waitEntered(t *testing.T, entered chan []*convSnap) []*convSnap {
|
||||
t.Helper()
|
||||
select {
|
||||
case s := <-entered:
|
||||
return s
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("aucun lot n'a atteint l'écriture")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// diskMessages relit le nombre de messages et le texte d'une discussion en base.
|
||||
func diskMessages(t *testing.T, id string) (int, string) {
|
||||
t.Helper()
|
||||
b := storeBytes(bkChat, convKey(id))
|
||||
if len(b) == 0 {
|
||||
return -1, ""
|
||||
}
|
||||
var st struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return len(st.Messages), string(b)
|
||||
}
|
||||
|
||||
func addUserMsg(text string) {
|
||||
conv.mu.Lock()
|
||||
conv.Messages = append(conv.Messages, Message{Role: "user", Content: text})
|
||||
conv.Log = append(conv.Log, LogEvent{Seq: len(conv.Log) + 1, Delta: map[string]any{"user": text}})
|
||||
conv.mu.Unlock()
|
||||
}
|
||||
|
||||
// Des instantanés pris en rafale depuis plusieurs goroutines sont écrits dans
|
||||
// l'ordre où ils ont été pris : jamais un plus ancien après un plus récent, et
|
||||
// la base finit sur le dernier état.
|
||||
func TestPersisterNEcritJamaisUnEtatPlusAncien(t *testing.T) {
|
||||
testHome(t)
|
||||
resetConvForTest()
|
||||
persistQ.flush()
|
||||
var mu sync.Mutex
|
||||
var seqs []uint64
|
||||
prev := persistCommit
|
||||
persistCommit = func(path string, s []*convSnap, r []toolResJob) ([]*convSnap, error) {
|
||||
mu.Lock()
|
||||
for _, x := range s {
|
||||
seqs = append(seqs, x.seq)
|
||||
}
|
||||
mu.Unlock()
|
||||
time.Sleep(2 * time.Millisecond) // laisse la file s'accumuler
|
||||
return prev(path, s, r)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
persistQ.flush()
|
||||
persistCommit = prev
|
||||
})
|
||||
|
||||
const workers, per = 8, 10
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < workers; g++ {
|
||||
wg.Add(1)
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < per; i++ {
|
||||
addUserMsg(fmt.Sprintf("g%d-%d", g, i))
|
||||
conv.persistAsync()
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
persistQ.flush()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for i := 1; i < len(seqs); i++ {
|
||||
if seqs[i] <= seqs[i-1] {
|
||||
t.Fatalf("instantané %d écrit après %d : un état plus ancien a écrasé un plus récent", seqs[i], seqs[i-1])
|
||||
}
|
||||
}
|
||||
if n, _ := diskMessages(t, convEnsureActive()); n != workers*per {
|
||||
t.Fatalf("la base finit sur %d messages, attendu %d (dernier état)", n, workers*per)
|
||||
}
|
||||
}
|
||||
|
||||
// persist (fin de tour, reset, bascule) est SYNCHRONE : il attend l'écriture en
|
||||
// vol, puis la sienne. Au retour de Reset, la base porte déjà le fil vide.
|
||||
func TestPersistSynchroneAttendLesEcrituresEnVol(t *testing.T) {
|
||||
testHome(t)
|
||||
resetConvForTest()
|
||||
entered, release := gatePersist(t)
|
||||
addUserMsg("avant le reset")
|
||||
conv.persistAsync()
|
||||
waitEntered(t, entered) // l'écrivain est retenu au milieu du lot
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
conv.Reset()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("Reset est revenu avant que l'écriture en vol soit terminée")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Reset bloqué")
|
||||
}
|
||||
if n, _ := diskMessages(t, convEnsureActive()); n != 0 {
|
||||
t.Fatalf("au retour de Reset, la base doit porter le fil vide (%d message(s))", n)
|
||||
}
|
||||
}
|
||||
|
||||
// « Voir plus » marche avant même que le résultat soit sur disque, et la copie
|
||||
// en mémoire disparaît une fois l'écriture faite.
|
||||
func TestToolResultServiAvantLEcriture(t *testing.T) {
|
||||
testHome(t)
|
||||
entered, release := gatePersist(t)
|
||||
long := strings.Repeat("sortie\n", 1000)
|
||||
id := saveToolResult(long)
|
||||
if id == "" {
|
||||
t.Fatal("résultat non enregistré")
|
||||
}
|
||||
waitEntered(t, entered)
|
||||
if got, ok := loadToolResult(id); !ok || got != long {
|
||||
t.Fatal("le résultat doit être servi depuis la mémoire tant qu'il n'est pas écrit")
|
||||
}
|
||||
close(release)
|
||||
persistQ.flush()
|
||||
if _, ok := persistQ.toolResPending(id); ok {
|
||||
t.Fatal("la copie en mémoire doit partir une fois le résultat écrit")
|
||||
}
|
||||
if got, ok := loadToolResult(id); !ok || got != long {
|
||||
t.Fatal("résultat perdu après l'écriture")
|
||||
}
|
||||
}
|
||||
|
||||
// Un résultat supprimé avec sa discussion avant d'être écrit n'est jamais écrit.
|
||||
func TestToolResultSupprimeAvantEcriture(t *testing.T) {
|
||||
testHome(t)
|
||||
entered, release := gatePersist(t)
|
||||
saveToolResult(strings.Repeat("a", 3000)) // retient l'écrivain
|
||||
waitEntered(t, entered)
|
||||
second := saveToolResult(strings.Repeat("b", 3000)) // en attente derrière
|
||||
deleteToolResultsFor(toolResConv(second))
|
||||
close(release)
|
||||
persistQ.flush()
|
||||
if _, ok := loadToolResult(second); ok {
|
||||
t.Fatal("un résultat supprimé avant son écriture a été écrit quand même")
|
||||
}
|
||||
}
|
||||
|
||||
// Un résultat que l'écrivain a DÉJÀ pris dans son lot, supprimé avant que ce
|
||||
// lot n'atteigne la base, ne renaît pas après la suppression. C'était la cause
|
||||
// des échecs intermittents de TestToolResultsSaveLoadDelete : la suppression ne
|
||||
// retirait que la file d'attente, et l'écriture en vol passait après elle.
|
||||
func TestToolResultSupprimePendantEcriture(t *testing.T) {
|
||||
testHome(t)
|
||||
entered, release := gatePersist(t)
|
||||
id := saveToolResult(strings.Repeat("a", 3000))
|
||||
waitEntered(t, entered) // le lot est pris, pas encore écrit
|
||||
deleteToolResultsFor(toolResConv(id))
|
||||
close(release)
|
||||
persistQ.flush()
|
||||
if _, ok := loadToolResult(id); ok {
|
||||
t.Fatal("un résultat supprimé pendant son écriture a été écrit quand même")
|
||||
}
|
||||
// L'annulation ne vaut que pour ce lot : un nouveau résultat s'écrit.
|
||||
again := saveToolResult(strings.Repeat("b", 3000))
|
||||
persistQ.flush()
|
||||
if _, ok := loadToolResult(again); !ok {
|
||||
t.Fatal("annulation restée collée : le résultat suivant n'a pas été écrit")
|
||||
}
|
||||
}
|
||||
|
||||
// compactLog rend exactement ce que donne la compaction de fin de tour, sans
|
||||
// toucher au journal d'origine.
|
||||
func TestCompactLogPurEgalFinDeTour(t *testing.T) {
|
||||
log := []LogEvent{
|
||||
{Seq: 1, TS: 10, Delta: map[string]any{"user": "q"}},
|
||||
{Seq: 2, TS: 11, Delta: map[string]any{"reasoning_content": "je ", "toks": 1}},
|
||||
{Seq: 3, TS: 12, Delta: map[string]any{"reasoning_content": "pense", "toks": 1}},
|
||||
{Seq: 4, TS: 13, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "done": false}}},
|
||||
{Seq: 5, TS: 14, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "done": true}}},
|
||||
{Seq: 6, TS: 15, Delta: map[string]any{"content": "bon", "toks": 1}},
|
||||
{Seq: 7, TS: 16, Delta: map[string]any{"content": "jour", "toks": 1}},
|
||||
}
|
||||
orig := append([]LogEvent(nil), log...)
|
||||
got := compactLog(log)
|
||||
if !reflect.DeepEqual(log, orig) {
|
||||
t.Fatal("compactLog a modifié le journal d'origine")
|
||||
}
|
||||
c := newTestConv()
|
||||
c.Log = append([]LogEvent(nil), log...)
|
||||
c.compactLogLocked()
|
||||
if !reflect.DeepEqual(got, c.Log) {
|
||||
t.Fatalf("compactLog diffère de la fin de tour :\n%v\n%v", got, c.Log)
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("attendu 4 événements (user, réflexion, outil fini, texte), got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// En plein tour, la base reçoit le journal compacté ; le journal en mémoire
|
||||
// (suivi par les abonnés) garde ses événements bruts.
|
||||
func TestPersistEnPleinTourEcritLeJournalCompacte(t *testing.T) {
|
||||
testHome(t)
|
||||
resetConvForTest()
|
||||
conv.mu.Lock()
|
||||
conv.Generating = true
|
||||
for i := 0; i < 50; i++ {
|
||||
conv.Log = append(conv.Log, LogEvent{Seq: i + 1, Delta: map[string]any{"content": "x", "toks": 1}})
|
||||
}
|
||||
conv.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
conv.mu.Lock()
|
||||
conv.Generating = false
|
||||
conv.mu.Unlock()
|
||||
})
|
||||
conv.persistAsync()
|
||||
persistQ.flush()
|
||||
var st struct {
|
||||
Log []LogEvent `json:"log"`
|
||||
}
|
||||
if err := json.Unmarshal(storeBytes(bkChat, convKey(convEnsureActive())), &st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(st.Log) != 1 || st.Log[0].Delta["content"] != strings.Repeat("x", 50) {
|
||||
t.Fatalf("journal écrit non compacté : %d événement(s)", len(st.Log))
|
||||
}
|
||||
conv.mu.Lock()
|
||||
n := len(conv.Log)
|
||||
conv.mu.Unlock()
|
||||
if n != 50 {
|
||||
t.Fatalf("le journal en mémoire ne doit pas être touché : %d événement(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Contenu et entrée d'index partent dans la MÊME écriture.
|
||||
func TestPersistEcritContenuEtIndexEnUneFois(t *testing.T) {
|
||||
testHome(t)
|
||||
resetConvForTest()
|
||||
persistQ.flush()
|
||||
var calls int
|
||||
var mu sync.Mutex
|
||||
prev := persistCommit
|
||||
persistCommit = func(path string, s []*convSnap, r []toolResJob) ([]*convSnap, error) {
|
||||
mu.Lock()
|
||||
calls++
|
||||
mu.Unlock()
|
||||
return prev(path, s, r)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
persistQ.flush()
|
||||
persistCommit = prev
|
||||
})
|
||||
id := convEnsureActive()
|
||||
addUserMsg("titre de la discussion")
|
||||
conv.persist()
|
||||
mu.Lock()
|
||||
n := calls
|
||||
mu.Unlock()
|
||||
if n != 1 {
|
||||
t.Fatalf("%d écritures pour un persist, attendu 1", n)
|
||||
}
|
||||
if c, _ := diskMessages(t, id); c != 1 {
|
||||
t.Fatalf("contenu non écrit (%d)", c)
|
||||
}
|
||||
for _, m := range convIndex() {
|
||||
if m.ID == id {
|
||||
if m.Turns != 1 || m.Title != "titre de la discussion" {
|
||||
t.Fatalf("entrée d'index non rafraîchie : %+v", m)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("discussion absente de l'index")
|
||||
}
|
||||
|
||||
// Supprimer une discussion pendant qu'une écriture la vise ne la fait pas
|
||||
// renaître : ni contenu, ni entrée d'index.
|
||||
func TestSuppressionPendantEcritureNeRessusciteRien(t *testing.T) {
|
||||
testHome(t)
|
||||
resetConvForTest()
|
||||
a := convEnsureActive()
|
||||
addUserMsg("fil A")
|
||||
conv.persist()
|
||||
b := convNew()
|
||||
if err := convSwitch(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entered, release := gatePersist(t)
|
||||
addUserMsg("fil A, suite")
|
||||
conv.persistAsync()
|
||||
waitEntered(t, entered) // un lot visant A est en vol
|
||||
addUserMsg("fil A, encore")
|
||||
conv.persistAsync() // un autre attend derrière
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- convDelete(a) }()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(release)
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("convDelete bloqué")
|
||||
}
|
||||
persistQ.flush()
|
||||
if n, _ := diskMessages(t, a); n != -1 {
|
||||
t.Fatalf("la discussion supprimée a été réécrite (%d messages)", n)
|
||||
}
|
||||
for _, m := range convIndex() {
|
||||
if m.ID == a {
|
||||
t.Fatal("la discussion supprimée est revenue dans l'index")
|
||||
}
|
||||
}
|
||||
if got := convEnsureActive(); got != b {
|
||||
t.Fatalf("active = %q, attendu %q", got, b)
|
||||
}
|
||||
}
|
||||
|
||||
// Basculer pendant qu'une écriture est en vol : l'ancien fil est écrit sous SON
|
||||
// identifiant, jamais sous celui de la discussion qu'on ouvre.
|
||||
func TestBasculePendantEcritureGardeChaqueFilChezLui(t *testing.T) {
|
||||
testHome(t)
|
||||
resetConvForTest()
|
||||
a := convEnsureActive()
|
||||
addUserMsg("seulement dans A")
|
||||
conv.persist()
|
||||
b := convNew()
|
||||
addUserMsg("seulement dans B")
|
||||
conv.persist()
|
||||
if err := convSwitch(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entered, release := gatePersist(t)
|
||||
addUserMsg("A encore")
|
||||
conv.persistAsync()
|
||||
waitEntered(t, entered)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- convSwitch(b) }()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(release)
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
persistQ.flush()
|
||||
if _, body := diskMessages(t, b); strings.Contains(body, "dans A") {
|
||||
t.Fatal("le fil A a été écrit sous l'identifiant de B")
|
||||
}
|
||||
if n, body := diskMessages(t, a); n != 2 || !strings.Contains(body, "A encore") {
|
||||
t.Fatalf("le fil A n'a pas gardé son dernier état (%d messages)", n)
|
||||
}
|
||||
conv.mu.Lock()
|
||||
got := len(conv.Messages)
|
||||
conv.mu.Unlock()
|
||||
if got != 1 {
|
||||
t.Fatalf("la bascule doit charger B (1 message), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Base neuve : l'instantané de StartTurn crée la discussion active TOUT DE
|
||||
// SUITE, avant l'écriture — generate, qui démarre juste après, voit la même
|
||||
// discussion (mode Code, critères, espace de travail) que la persistance.
|
||||
func TestPersistAsyncCreeLaDiscussionActiveAvantGenerate(t *testing.T) {
|
||||
testHome(t)
|
||||
resetConvForTest()
|
||||
entered, release := gatePersist(t)
|
||||
defer close(release)
|
||||
addUserMsg("premier message")
|
||||
conv.persistAsync()
|
||||
snaps := waitEntered(t, entered)
|
||||
id := getStr(bkChat, ckActive)
|
||||
if id == "" {
|
||||
t.Fatal("la discussion active doit exister dès le retour de persistAsync")
|
||||
}
|
||||
if got := convEnsureActive(); got != id {
|
||||
t.Fatalf("generate verrait %q, la persistance %q", got, id)
|
||||
}
|
||||
if len(snaps) != 1 || snaps[0].id != id {
|
||||
t.Fatalf("l'instantané vise %v, attendu %q", snaps, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Base neuve, appels simultanés : un seul identifiant est forgé.
|
||||
func TestConvEnsureActiveConcurrentForgeUnSeulID(t *testing.T) {
|
||||
testHome(t)
|
||||
var wg sync.WaitGroup
|
||||
ids := make([]string, 8)
|
||||
for i := range ids {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
ids[i] = convEnsureActive()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for _, id := range ids[1:] {
|
||||
if id != ids[0] {
|
||||
t.Fatalf("identifiants divergents : %v", ids)
|
||||
}
|
||||
}
|
||||
if n := len(convIndex()); n != 1 {
|
||||
t.Fatalf("%d entrées d'index, attendu 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Un outil qui finit juste après la suppression de sa discussion : son résultat
|
||||
// n'est ni écrit ni gardé en mémoire pour toujours.
|
||||
func TestToolResultApresSuppressionNeFuitPas(t *testing.T) {
|
||||
testHome(t)
|
||||
resetConvForTest()
|
||||
a := convEnsureActive()
|
||||
convNew()
|
||||
persistQ.forget(a)
|
||||
key := a + ".deadbeef"
|
||||
persistQ.enqueueToolRes(toolResJob{path: dbPath(), key: key, plain: "orphelin"})
|
||||
persistQ.flush()
|
||||
if _, ok := persistQ.toolResPending(key); ok {
|
||||
t.Fatal("résultat d'une discussion supprimée gardé en mémoire")
|
||||
}
|
||||
if b := getBytes(bkToolRes, key); b != nil {
|
||||
t.Fatal("résultat d'une discussion supprimée écrit sur disque")
|
||||
}
|
||||
}
|
||||
|
||||
// Base neuve, sans discussion active : un résultat d'outil (tâche de fond) ne
|
||||
// forge PAS de discussion vide dans la liste — il va sous « nosession », comme
|
||||
// avant le cache.
|
||||
func TestSaveToolResultNeCreePasDeDiscussion(t *testing.T) {
|
||||
testHome(t)
|
||||
id := saveToolResult(strings.Repeat("r", 3000))
|
||||
if !strings.HasPrefix(id, "nosession.") {
|
||||
t.Fatalf("id = %q, attendu nosession.*", id)
|
||||
}
|
||||
persistQ.flush()
|
||||
if getStr(bkChat, ckActive) != "" || len(convIndex()) != 0 {
|
||||
t.Fatal("saveToolResult a créé une discussion")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
package loki
|
||||
|
||||
// chat_prewarm.go — PREWARM : préparer le prochain tour pendant que
|
||||
// l'utilisateur lit.
|
||||
//
|
||||
// Pourquoi. Certains recalculs sont inévitables : le prompt réécrit par une
|
||||
// compaction (12 à 20k jetons), le dernier message assistant rendu autrement
|
||||
// au tour suivant (réflexion retirée par le gabarit, raisonnement jamais
|
||||
// renvoyé), la discussion reprise après une tâche planifiée qui a pris le slot.
|
||||
// Sans la clé, ils tombent sur le premier jeton du message suivant : des
|
||||
// secondes sur un 27B, des dizaines sur un MoE aux experts en RAM.
|
||||
//
|
||||
// Avec la clé (off par défaut), Loki envoie au moteur local, dès qu'il est
|
||||
// libre, la requête du prochain tour telle qu'elle partira — assemblée par
|
||||
// les MÊMES fonctions (turnViewDry, wireMessages, buildChatPayload), contenu
|
||||
// vivant, rien de figé ni de mémorisé pour gagner du cache — suivie d'un
|
||||
// message utilisateur sentinelle « . », avec max_tokens 1. Le moteur calcule
|
||||
// tout le préfixe et pose un point de reprise au début de ce dernier message :
|
||||
// le vrai tour ne calcule plus que son propre message. Le jeton produit et la
|
||||
// sentinelle sont jetés ; rien n'est persisté, ni dans la discussion, ni dans
|
||||
// le journal, ni dans les compteurs (CtxUsed, stats, effort appris).
|
||||
//
|
||||
// Ce que le modèle voit ne change pas : llama-server ne reprend un état que sur
|
||||
// un préfixe de jetons identique, et la vraie requête est construite à l'envoi,
|
||||
// comme toujours. Au pire, le préchauffage ne sert à rien.
|
||||
//
|
||||
// Il ne passe jamais devant un vrai travail :
|
||||
// - moteur local seulement, un seul slot (/props total_slots = 1 : ni
|
||||
// PARALLEL ni -np d'EXTRA_ARGS ne peuvent le contredire) — ou les deux de
|
||||
// SIDE_SLOT en service, et il vise alors le slot de la discussion
|
||||
// (id_slot 0, llm_sideslot.go) ;
|
||||
// - jamais pendant un tour, une tâche, une compaction, un bench, un travail
|
||||
// annexe, ni s'il reste une requête de Loki en vol (compteur de
|
||||
// llm_slots.go, qu'il tient lui-même : l'isolation des slots n'efface rien
|
||||
// pendant qu'il tourne) ;
|
||||
// - toute requête de Loki vers le moteur l'annule à l'instant de partir —
|
||||
// sauf un tour de chat dont la requête PROLONGE exactement le préfixe
|
||||
// préchauffé (empreintes des messages sérialisés, des outils et des
|
||||
// arguments du gabarit) : elle suit alors dans le même slot et réutilise
|
||||
// ce qui vient d'être calculé, sans rien attendre d'inutile ;
|
||||
// - un seul en vol ; un nouveau déclencheur annule l'ancien ;
|
||||
// - aucune erreur ne remonte : 400 (contexte plein), 503 (chargement), 500
|
||||
// (gabarit) sont lâchés, sans compaction, sans relance, sans rien apprendre.
|
||||
//
|
||||
// Déclencheurs : fin d'un tour (compaction de fin de tour comprise) quand rien
|
||||
// n'attend dans la file, fin d'une tâche planifiée (slot déjà effacé, projet
|
||||
// forcé déjà levé). PREWARM=full ajoute le changement de discussion, après 3 s
|
||||
// sans nouveau changement. Les capacités sont celles du dernier tour de la
|
||||
// discussion, gardées en mémoire ; en mode Code, le rôle est celui d'un message
|
||||
// ordinaire (builder) — une demande de plan diverge et annule.
|
||||
//
|
||||
// Limites, sans effet sur la fidélité : un moteur sans point de reprise au
|
||||
// début des messages utilisateur, ou des images juste avant (le moteur n'en
|
||||
// pose pas après un bloc d'image), retombe sur un recalcul complet et exact ;
|
||||
// changer agent, web ou mémoire avant d'envoyer rend le préchauffage inutile.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// prewarmMode : « on », « full » ou "" (off). Jamais pour un preset externe.
|
||||
func prewarmMode(cfg map[string]string) string {
|
||||
if isExternalConfig(cfg) {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(cfg["PREWARM"])) {
|
||||
case "on", "1", "true", "yes", "oui":
|
||||
return "on"
|
||||
case "full":
|
||||
return "full"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Déclencheurs (pour le journal).
|
||||
const (
|
||||
prewarmTurnEnd = "fin-tour"
|
||||
prewarmAfterTask = "après-tâche"
|
||||
prewarmSwitch = "discussion"
|
||||
)
|
||||
|
||||
// prewarmSentinel : le message utilisateur qui tient la place du vrai.
|
||||
const prewarmSentinel = "."
|
||||
|
||||
// prewarmRun : un préchauffage en vol. prefix couvre ses n premiers messages
|
||||
// (sans la sentinelle), opts les champs qui façonnent le rendu.
|
||||
type prewarmRun struct {
|
||||
n int
|
||||
prefix uint64
|
||||
opts uint64
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// prewarmCur : le préchauffage en vol, protégé par engineGate.mu (llm_slots.go).
|
||||
// prewarmLive le reflète sans verrou : sans préchauffage, une requête ne
|
||||
// calcule aucune empreinte.
|
||||
var (
|
||||
prewarmCur *prewarmRun
|
||||
prewarmLive atomic.Bool
|
||||
)
|
||||
|
||||
// prewarmDropLocked annule p, engineGate.mu tenu. Le client ferme la
|
||||
// connexion, et llama-server arrête la tâche (au plus un micro-lot plus tard).
|
||||
func prewarmDropLocked(p *prewarmRun) {
|
||||
if prewarmCur == p {
|
||||
prewarmCur = nil
|
||||
prewarmLive.Store(false)
|
||||
}
|
||||
p.cancel()
|
||||
}
|
||||
|
||||
// prewarmBegin inscrit p comme LA requête en vol, si le moteur est libre de
|
||||
// toute autre requête de Loki. end le retire (une seule fois).
|
||||
func prewarmBegin(p *prewarmRun) (end func(), ok bool) {
|
||||
engineGate.mu.Lock()
|
||||
defer engineGate.mu.Unlock()
|
||||
if engineGate.inflight > 0 || prewarmCur != nil {
|
||||
return nil, false
|
||||
}
|
||||
prewarmCur = p
|
||||
prewarmLive.Store(true)
|
||||
engineGate.inflight++
|
||||
engineGate.seq++ // le slot ne porte plus le tour d'avant (engineSlotHolds)
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
engineGate.mu.Lock()
|
||||
engineGate.inflight--
|
||||
if prewarmCur == p {
|
||||
prewarmCur = nil
|
||||
prewarmLive.Store(false)
|
||||
}
|
||||
engineGate.mu.Unlock()
|
||||
})
|
||||
}, true
|
||||
}
|
||||
|
||||
// prewarmStop annule le préchauffage en vol et rend celui-ci (nil sinon), pour
|
||||
// qu'on puisse attendre sa fin.
|
||||
func prewarmStop() *prewarmRun {
|
||||
// Cas de toujours : rien en vol, et pas de verrou à prendre (l'effacement
|
||||
// d'un slot peut le tenir deux secondes).
|
||||
if !prewarmLive.Load() {
|
||||
return nil
|
||||
}
|
||||
engineGate.mu.Lock()
|
||||
defer engineGate.mu.Unlock()
|
||||
p := prewarmCur
|
||||
if p != nil {
|
||||
prewarmDropLocked(p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// matches : la requête dont prefix est l'empreinte cumulée des messages
|
||||
// (perfPrefix) prolonge-t-elle exactement ce préchauffage ?
|
||||
func (p *prewarmRun) matches(prefix []uint64, opts uint64) bool {
|
||||
return p.n > 0 && len(prefix) > p.n && prefix[p.n-1] == p.prefix && opts == p.opts
|
||||
}
|
||||
|
||||
// prewarmOptsKeys : les champs du corps, hors messages, qui changent le prompt
|
||||
// rendu par le gabarit. L'échantillonnage n'y est pas : il ne touche pas au
|
||||
// prompt.
|
||||
var prewarmOptsKeys = []string{"tools", "parallel_tool_calls", "tool_choice", "chat_template_kwargs", "reasoning_effort"}
|
||||
|
||||
func prewarmOptsHash(payload map[string]any) uint64 {
|
||||
h := fnv.New64a()
|
||||
for _, k := range prewarmOptsKeys {
|
||||
h.Write([]byte(k))
|
||||
h.Write([]byte{0})
|
||||
if v, ok := payload[k]; ok {
|
||||
b, _ := json.Marshal(v)
|
||||
h.Write(b)
|
||||
}
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
return h.Sum64()
|
||||
}
|
||||
|
||||
// prewarmKeeper : pour runChat, ce qui reconnaît un préchauffage à garder. nil
|
||||
// (tout annuler) hors tour de chat, ou sans préchauffage en vol — dans ce cas,
|
||||
// le cas de toujours, rien n'est calculé.
|
||||
func prewarmKeeper(kind string, sent []Message, payload map[string]any) func(*prewarmRun) bool {
|
||||
if kind != perfMain || !prewarmLive.Load() {
|
||||
return nil
|
||||
}
|
||||
prefix, opts := perfPrefix(sent), prewarmOptsHash(payload)
|
||||
return func(p *prewarmRun) bool { return p.matches(prefix, opts) }
|
||||
}
|
||||
|
||||
// Capacités du dernier tour de chaque discussion, en mémoire seulement : le
|
||||
// client les envoie avec chaque message, le serveur ne les connaît pas sinon.
|
||||
var prewarmCaps struct {
|
||||
mu sync.Mutex
|
||||
m map[string]Caps
|
||||
}
|
||||
|
||||
const prewarmCapsMax = 64
|
||||
|
||||
func prewarmNoteCaps(convID string, caps Caps) {
|
||||
if convID == "" {
|
||||
return
|
||||
}
|
||||
prewarmCaps.mu.Lock()
|
||||
defer prewarmCaps.mu.Unlock()
|
||||
if prewarmCaps.m == nil {
|
||||
prewarmCaps.m = map[string]Caps{}
|
||||
}
|
||||
if _, ok := prewarmCaps.m[convID]; !ok && len(prewarmCaps.m) >= prewarmCapsMax {
|
||||
for k := range prewarmCaps.m {
|
||||
delete(prewarmCaps.m, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
prewarmCaps.m[convID] = caps
|
||||
}
|
||||
|
||||
func prewarmCapsFor(convID string) (Caps, bool) {
|
||||
prewarmCaps.mu.Lock()
|
||||
defer prewarmCaps.mu.Unlock()
|
||||
c, ok := prewarmCaps.m[convID]
|
||||
return c, ok
|
||||
}
|
||||
|
||||
// prewarmNearMidnight : la date du jour fait partie du prompt système ; à
|
||||
// moins de 10 min de minuit, le prochain message a toutes les chances de
|
||||
// partir avec la suivante.
|
||||
func prewarmNearMidnight(now time.Time) bool {
|
||||
y, m, d := now.Date()
|
||||
next := time.Date(y, m, d+1, 0, 0, 0, 0, now.Location())
|
||||
return next.Sub(now) < 10*time.Minute
|
||||
}
|
||||
|
||||
// Points d'accroche remplaçables par les tests.
|
||||
var (
|
||||
// prewarmAsync : false = le préchauffage tourne dans l'appelant (tests).
|
||||
prewarmAsync = true
|
||||
// prewarmSlots : nombre de slots du moteur local (/props total_slots).
|
||||
prewarmSlots = func(ep chatEndpoint) int {
|
||||
var props struct {
|
||||
TotalSlots int `json:"total_slots"`
|
||||
}
|
||||
iso := slotIsolator{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: ep.auth, client: http.DefaultClient}
|
||||
if err := iso.get(context.Background(), "/props", &props); err != nil {
|
||||
return 0
|
||||
}
|
||||
return props.TotalSlots
|
||||
}
|
||||
prewarmSwitchDelay = 3 * time.Second
|
||||
// prewarmTimeout : borne d'un préchauffage (un MoE aux experts en RAM met
|
||||
// des minutes sur 20k jetons). Toute vraie requête l'annule bien avant.
|
||||
prewarmTimeout = 15 * time.Minute
|
||||
)
|
||||
|
||||
// prewarmKick : un déclencheur. Sans la clé, rien — ni goroutine, ni requête.
|
||||
func (c *Conversation) prewarmKick(why string) {
|
||||
if prewarmMode(ReadConfig()) == "" {
|
||||
return
|
||||
}
|
||||
if prewarmAsync {
|
||||
go c.prewarm(why)
|
||||
return
|
||||
}
|
||||
c.prewarm(why)
|
||||
}
|
||||
|
||||
// prewarmAfterSwitch : changement de discussion. Le préchauffage de l'ancienne
|
||||
// est annulé ; avec PREWARM=full, la nouvelle est préparée si l'utilisateur y
|
||||
// reste 3 s (cliquer d'une discussion à l'autre ne lance rien).
|
||||
func prewarmAfterSwitch() {
|
||||
prewarmStop() // sa fin n'est pas attendue : le changement ne doit pas bloquer
|
||||
if prewarmMode(ReadConfig()) != "full" {
|
||||
return
|
||||
}
|
||||
id := convActiveID()
|
||||
time.AfterFunc(prewarmSwitchDelay, func() {
|
||||
if convActiveID() == id {
|
||||
conv.prewarmKick(prewarmSwitch)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// prewarmBodyFor construit le corps du préchauffage de c, ou "" avec la raison
|
||||
// de s'abstenir. Lecture seule : rien n'est modifié dans la discussion.
|
||||
func (c *Conversation) prewarmBodyFor(ep chatEndpoint, cfg map[string]string) (payload map[string]any, run *prewarmRun, skip string) {
|
||||
caps, ok := prewarmCapsFor(convActiveID())
|
||||
if !ok {
|
||||
return nil, nil, "capacités du dernier tour inconnues"
|
||||
}
|
||||
caps = withTurnRole(caps, prewarmSentinel)
|
||||
|
||||
c.mu.Lock()
|
||||
if c.Generating {
|
||||
c.mu.Unlock()
|
||||
return nil, nil, "moteur occupé"
|
||||
}
|
||||
msgs := append([]Message(nil), c.Messages...)
|
||||
ctxNow := c.ctxNowLocked(msgs, true)
|
||||
peak := c.genPeak
|
||||
c.mu.Unlock()
|
||||
|
||||
hasUser := false
|
||||
for _, m := range msgs {
|
||||
if m.Role == "user" {
|
||||
hasUser = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// Sans message utilisateur dans l'historique, le contexte projet irait en
|
||||
// tête de la sentinelle (normalizeSystemMessages) : rien à préparer.
|
||||
if !hasUser {
|
||||
return nil, nil, "discussion vide"
|
||||
}
|
||||
msgs = append(msgs, Message{Role: "user", Content: prewarmSentinel})
|
||||
// Le prochain tour compactera d'abord : son prompt ne sera pas celui-ci.
|
||||
if compactNeeded(msgs, ctxNow, peak) {
|
||||
return nil, nil, "compaction attendue au prochain tour"
|
||||
}
|
||||
|
||||
sent, tools := c.turnViewDry(caps, msgs, caps.Agent)
|
||||
_, effort, _, kwargs := turnReasoning(cfg)
|
||||
wire, _ := wireMessages(sent, false, turnEchoPolicy(false))
|
||||
n := len(wire)
|
||||
if n < 2 || wire[n-1].Role != "user" {
|
||||
return nil, nil, "assemblage inattendu"
|
||||
}
|
||||
if s, _ := wire[n-1].Content.(string); s != prewarmSentinel {
|
||||
return nil, nil, "assemblage inattendu"
|
||||
}
|
||||
payload = buildChatPayload(ep, wire, 0.7, chatPayloadOpts{tools: tools, effort: effort, kwargs: kwargs})
|
||||
// Seuls les champs de réponse changent : une réponse d'un seul jeton, non
|
||||
// streamée (jetée de toute façon).
|
||||
payload["stream"] = false
|
||||
delete(payload, "stream_options")
|
||||
payload["max_tokens"] = 1
|
||||
return payload, &prewarmRun{n: n - 1, prefix: perfPrefix(wire)[n-2], opts: prewarmOptsHash(payload)}, ""
|
||||
}
|
||||
|
||||
var prewarmLogged sync.Map // raison d'échec → déjà journalisée
|
||||
|
||||
func prewarmLogOnce(key, format string, args ...any) {
|
||||
if _, dup := prewarmLogged.LoadOrStore(key, true); dup {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[prewarm] "+format+"\n", args...)
|
||||
}
|
||||
|
||||
// prewarm : un préchauffage complet, ou rien. Jamais d'erreur rendue.
|
||||
func (c *Conversation) prewarm(why string) {
|
||||
cfg := ReadConfig()
|
||||
if prewarmMode(cfg) == "" {
|
||||
return
|
||||
}
|
||||
ep := resolveChatEndpoint()
|
||||
if ep.External {
|
||||
return
|
||||
}
|
||||
// Un seul en vol : le nouveau déclencheur remplace l'ancien.
|
||||
if p := prewarmStop(); p != nil {
|
||||
select {
|
||||
case <-p.done:
|
||||
case <-time.After(5 * time.Second):
|
||||
return
|
||||
}
|
||||
}
|
||||
if sideJobs.Load() > 0 || benchRunning() || tuneGuard() != nil || prewarmNearMidnight(time.Now()) {
|
||||
return
|
||||
}
|
||||
if !healthCheck() {
|
||||
return
|
||||
}
|
||||
// Un seul slot, ou les deux de SIDE_SLOT : le préchauffage vise alors le
|
||||
// slot de la discussion (id_slot 0), celui où partira le vrai tour.
|
||||
slot := -1
|
||||
if n := prewarmSlots(ep); n != 1 {
|
||||
if slot = engineSlotFor(ep, true); n != 2 || slot != 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
payload, run, skip := c.prewarmBodyFor(ep, cfg)
|
||||
if skip != "" {
|
||||
return
|
||||
}
|
||||
setEngineSlot(payload, slot)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// SLOT_PERSIST : l'état gardé de la discussion d'abord, s'il y en a un — le
|
||||
// préchauffage le prolonge au lieu de tout recalculer.
|
||||
slotPersistRestore(ep, convActiveID())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), prewarmTimeout)
|
||||
defer cancel()
|
||||
run.cancel, run.done = cancel, make(chan struct{})
|
||||
defer close(run.done)
|
||||
end, ok := prewarmBegin(run)
|
||||
if !ok {
|
||||
return // une requête de Loki est partie entre-temps
|
||||
}
|
||||
defer end()
|
||||
ctx = withPerf(ctx, perfPrewarm, convActiveID())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ep.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ep.auth(req.Header.Set)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
prewarmLogOnce("net", "moteur injoignable (%s) : %v", why, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
prewarmLogOnce(resp.Status, "refusé par le moteur (%s) : %s %s", why, resp.Status, strings.TrimSpace(string(raw[:min(len(raw), 200)])))
|
||||
return
|
||||
}
|
||||
// Télémétrie seulement (kind=prewarm) : la réponse est jetée.
|
||||
rec := perfRecord(perfRecFromWire(perfTagOf(ctx), decodePerfWire(raw)), nil)
|
||||
// Après une tâche, c'est lui qui retrouve (ou non) la conversation dans le
|
||||
// cache RAM : la vérification de llm_slots.go se fait donc ici — le vrai
|
||||
// tour, servi depuis le slot préchauffé, n'aurait plus rien à en dire. Le
|
||||
// conseil va au journal (aucun fil où l'afficher).
|
||||
if rec.Total > 0 {
|
||||
cached := 0
|
||||
if rec.Cached != nil {
|
||||
cached = *rec.Cached
|
||||
}
|
||||
noteEnginePrompt(rec.Total, cached, rec.Cached != nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// moteurPrewarm : faux llama-server qui note TOUTES les requêtes (chemin et
|
||||
// corps), sert /health et /props, et répond aux complétions streamées comme aux
|
||||
// autres.
|
||||
type moteurPrewarm struct {
|
||||
mu sync.Mutex
|
||||
reqs []prewarmReq
|
||||
slots int
|
||||
}
|
||||
|
||||
type prewarmReq struct{ path, body string }
|
||||
|
||||
func (m *moteurPrewarm) start(t *testing.T) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var buf bytes.Buffer
|
||||
_, _ = buf.ReadFrom(r.Body)
|
||||
m.mu.Lock()
|
||||
m.reqs = append(m.reqs, prewarmReq{r.URL.Path, buf.String()})
|
||||
slots := m.slots
|
||||
m.mu.Unlock()
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
sendJSON(w, 200, map[string]any{"status": "ok"})
|
||||
case "/props":
|
||||
sendJSON(w, 200, map[string]any{"total_slots": slots, "build_info": "b9000-x"})
|
||||
default:
|
||||
if strings.Contains(buf.String(), `"stream":true`) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(sseChunk("ok.") + sseFinal("stop", 100, 2) + "data: [DONE]\n\n"))
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"choices": []any{map[string]any{"message": map[string]any{"content": "x"}}},
|
||||
"usage": map[string]any{"prompt_tokens": 120, "completion_tokens": 1},
|
||||
"timings": map[string]any{"prompt_n": 4, "cache_n": 116, "predicted_n": 1},
|
||||
})
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *moteurPrewarm) all() []prewarmReq {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return append([]prewarmReq(nil), m.reqs...)
|
||||
}
|
||||
|
||||
// completions : corps des complétions, streamées ou non.
|
||||
func (m *moteurPrewarm) completions() []string {
|
||||
var out []string
|
||||
for _, r := range m.all() {
|
||||
if r.path == "/v1/chat/completions" {
|
||||
out = append(out, r.body)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// prewarmSetup : base neuve, discussion active, faux moteur à un slot,
|
||||
// préchauffage synchrone. keys : réglages posés.
|
||||
func prewarmSetup(t *testing.T, keys map[string]string) *moteurPrewarm {
|
||||
t.Helper()
|
||||
withWorkspace(t)
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
setProjectOverride("")
|
||||
t.Cleanup(func() {
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
setProjectOverride("")
|
||||
})
|
||||
ensureDefaultProject()
|
||||
freshTplProbe(t)
|
||||
convEnsureActive()
|
||||
prewarmAsync = false
|
||||
prewarmCaps.mu.Lock()
|
||||
prewarmCaps.m = nil
|
||||
prewarmCaps.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
prewarmAsync = true
|
||||
prewarmCaps.mu.Lock()
|
||||
prewarmCaps.m = nil
|
||||
prewarmCaps.mu.Unlock()
|
||||
})
|
||||
if err := SetConfigKey("MODEL", "/models/"+echoTestModel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, v := range keys {
|
||||
if err := SetConfigKey(k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m := &moteurPrewarm{slots: 1}
|
||||
m.start(t)
|
||||
return m
|
||||
}
|
||||
|
||||
// prewarmTurn : un tour de chat comme StartTurn le lance (capacités notées pour
|
||||
// le préchauffage), joué jusqu'au bout, préchauffage éventuel compris.
|
||||
func prewarmTurn(t *testing.T, c *Conversation, caps Caps, text string) {
|
||||
t.Helper()
|
||||
prewarmNoteCaps(convActiveID(), caps)
|
||||
c.mu.Lock()
|
||||
c.Messages = append(c.Messages, um(text))
|
||||
c.mu.Unlock()
|
||||
c.generate(context.Background(), withTurnRole(caps, text), 0.7, c.epoch)
|
||||
}
|
||||
|
||||
// Clé absente : pas une requête de plus au moteur — ni complétion, ni /health,
|
||||
// ni /props — et la conversation est celle d'avant.
|
||||
func TestPrewarmDefautAucuneRequete(t *testing.T) {
|
||||
m := prewarmSetup(t, nil)
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
prewarmTurn(t, c, caps, "bonjour")
|
||||
prewarmTurn(t, c, caps, "et ensuite ?")
|
||||
reqs := m.all()
|
||||
if len(reqs) != 2 {
|
||||
t.Fatalf("2 requêtes attendues (une par tour), %d reçues : %+v", len(reqs), reqs)
|
||||
}
|
||||
for _, r := range reqs {
|
||||
if r.path != "/v1/chat/completions" || strings.Contains(r.body, `"max_tokens"`) || !strings.Contains(r.body, `"stream":true`) {
|
||||
t.Fatalf("requête inattendue sans la clé : %s %s", r.path, r.body)
|
||||
}
|
||||
}
|
||||
// La fin de tour ne lance rien non plus en direct.
|
||||
c.prewarm(prewarmTurnEnd)
|
||||
if n := len(m.all()); n != 2 {
|
||||
t.Fatalf("prewarm sans la clé a parlé au moteur (%d requêtes)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Preset externe : la clé est sans effet.
|
||||
func TestPrewarmJamaisExterne(t *testing.T) {
|
||||
cfg := map[string]string{"PREWARM": "on", extKeyFlag: "1", extKeyURL: "http://127.0.0.1:1"}
|
||||
if prewarmMode(cfg) != "" {
|
||||
t.Fatal("PREWARM actif sur un preset externe")
|
||||
}
|
||||
for v, want := range map[string]string{"": "", "off": "", "on": "on", "1": "on", "full": "full", "FULL": "full", "x": ""} {
|
||||
if got := prewarmMode(map[string]string{"PREWARM": v}); got != want {
|
||||
t.Fatalf("PREWARM=%q : %q, attendu %q", v, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Avec la clé : la requête de préchauffage est celle du prochain tour, sentinelle
|
||||
// en plus — mêmes messages jusqu'au dernier, mêmes outils et arguments du
|
||||
// gabarit — avec max_tokens 1, sans flux. Rien n'en reste dans la discussion,
|
||||
// et la télémétrie la range sous kind=prewarm.
|
||||
func TestPrewarmEgalTourSuivantPlusSentinelle(t *testing.T) {
|
||||
m := prewarmSetup(t, map[string]string{"PREWARM": "on", "REASONING_EFFORT": "high", "TOP_P": "0.9"})
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
prewarmTurn(t, c, caps, "bonjour")
|
||||
comps := m.completions()
|
||||
if len(comps) != 2 {
|
||||
t.Fatalf("tour + préchauffage attendus, %d complétions", len(comps))
|
||||
}
|
||||
var pw map[string]any
|
||||
if err := json.Unmarshal([]byte(comps[1]), &pw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pw["stream"] != false || pw["max_tokens"] != float64(1) || pw["stream_options"] != nil {
|
||||
t.Fatalf("champs de réponse du préchauffage : stream=%v max_tokens=%v stream_options=%v", pw["stream"], pw["max_tokens"], pw["stream_options"])
|
||||
}
|
||||
c.mu.Lock()
|
||||
for _, msg := range c.Messages {
|
||||
if s, _ := msg.Content.(string); s == prewarmSentinel {
|
||||
c.mu.Unlock()
|
||||
t.Fatal("la sentinelle a été rangée dans la discussion")
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
prewarmTurn(t, c, caps, "et ensuite ?")
|
||||
comps = m.completions()
|
||||
var real map[string]any
|
||||
if err := json.Unmarshal([]byte(comps[2]), &real); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pm, rm := pw["messages"].([]any), real["messages"].([]any)
|
||||
if len(pm) != len(rm) {
|
||||
t.Fatalf("préchauffage %d messages, tour suivant %d", len(pm), len(rm))
|
||||
}
|
||||
if !reflect.DeepEqual(pm[:len(pm)-1], rm[:len(rm)-1]) {
|
||||
t.Fatalf("préfixe différent du tour suivant :\npréchauffage %v\ntour %v", pm[:len(pm)-1], rm[:len(rm)-1])
|
||||
}
|
||||
if last := pm[len(pm)-1].(map[string]any); last["role"] != "user" || last["content"] != prewarmSentinel {
|
||||
t.Fatalf("dernier message du préchauffage : %v", last)
|
||||
}
|
||||
for _, k := range append([]string{"temperature", "top_p", "model"}, prewarmOptsKeys...) {
|
||||
if !reflect.DeepEqual(pw[k], real[k]) {
|
||||
t.Fatalf("champ %s : préchauffage %v, tour %v", k, pw[k], real[k])
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, r := range perfLog.snapshot() {
|
||||
if r.Kind == perfPrewarm {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("aucune complétion kind=prewarm dans la télémétrie")
|
||||
}
|
||||
}
|
||||
|
||||
// Les garde-fous : plus d'un slot, tour en cours, capacités inconnues, travail
|
||||
// annexe — aucune complétion de préchauffage.
|
||||
func TestPrewarmGardeFous(t *testing.T) {
|
||||
m := prewarmSetup(t, map[string]string{"PREWARM": "on"})
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true}
|
||||
c.mu.Lock()
|
||||
c.Messages = []Message{um("bonjour"), am("salut")}
|
||||
c.mu.Unlock()
|
||||
count := func() int { return len(m.completions()) }
|
||||
|
||||
// Capacités inconnues (aucun tour joué dans cette discussion).
|
||||
c.prewarm(prewarmTurnEnd)
|
||||
if count() != 0 {
|
||||
t.Fatal("préchauffage sans capacités connues")
|
||||
}
|
||||
prewarmNoteCaps(convActiveID(), caps)
|
||||
|
||||
m.mu.Lock()
|
||||
m.slots = 2
|
||||
m.mu.Unlock()
|
||||
c.prewarm(prewarmTurnEnd)
|
||||
if count() != 0 {
|
||||
t.Fatal("préchauffage avec deux slots")
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.slots = 1
|
||||
m.mu.Unlock()
|
||||
|
||||
c.mu.Lock()
|
||||
c.Generating = true
|
||||
c.mu.Unlock()
|
||||
c.prewarm(prewarmTurnEnd)
|
||||
c.mu.Lock()
|
||||
c.Generating = false
|
||||
c.mu.Unlock()
|
||||
if count() != 0 {
|
||||
t.Fatal("préchauffage pendant un tour")
|
||||
}
|
||||
|
||||
sideJobs.Add(1)
|
||||
c.prewarm(prewarmTurnEnd)
|
||||
sideJobs.Add(-1)
|
||||
if count() != 0 {
|
||||
t.Fatal("préchauffage pendant un travail annexe")
|
||||
}
|
||||
|
||||
end := engineRequestStart()
|
||||
c.prewarm(prewarmTurnEnd)
|
||||
end()
|
||||
if count() != 0 {
|
||||
t.Fatal("préchauffage avec une requête de Loki en vol")
|
||||
}
|
||||
|
||||
c.prewarm(prewarmTurnEnd)
|
||||
if count() != 1 {
|
||||
t.Fatalf("toutes conditions réunies : 1 préchauffage attendu, %d", count())
|
||||
}
|
||||
}
|
||||
|
||||
// Toute requête de Loki annule le préchauffage en vol, sauf un tour de chat qui
|
||||
// prolonge exactement son préfixe.
|
||||
func TestPrewarmAnnuleSaufPrefixeExact(t *testing.T) {
|
||||
hist := []Message{{Role: "system", Content: "sys"}, um("a"), am("b")}
|
||||
pwMsgs := append(append([]Message(nil), hist...), um(prewarmSentinel))
|
||||
real := append(append([]Message(nil), hist...), um("suite"))
|
||||
payload := map[string]any{"tools": []string{"t"}, "parallel_tool_calls": false}
|
||||
other := []Message{{Role: "system", Content: "sys"}, um("a"), am("c"), um("suite")}
|
||||
|
||||
// Sans préchauffage en vol, rien n'est calculé.
|
||||
if prewarmKeeper(perfMain, real, payload) != nil {
|
||||
t.Fatal("empreintes calculées sans préchauffage en vol")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
keep func() func(*prewarmRun) bool // construit préchauffage en vol
|
||||
kept bool
|
||||
}{
|
||||
{"tour qui prolonge", func() func(*prewarmRun) bool { return prewarmKeeper(perfMain, real, payload) }, true},
|
||||
{"autres outils", func() func(*prewarmRun) bool {
|
||||
return prewarmKeeper(perfMain, real, map[string]any{"tools": []string{"u"}, "parallel_tool_calls": false})
|
||||
}, false},
|
||||
{"autre historique", func() func(*prewarmRun) bool { return prewarmKeeper(perfMain, other, payload) }, false},
|
||||
{"préfixe seul, rien après", func() func(*prewarmRun) bool { return prewarmKeeper(perfMain, hist, payload) }, false},
|
||||
{"compaction", func() func(*prewarmRun) bool { return prewarmKeeper(perfCompact, real, payload) }, false},
|
||||
{"requête quelconque", func() func(*prewarmRun) bool { return nil }, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
run := &prewarmRun{n: len(pwMsgs) - 1, prefix: perfPrefix(pwMsgs)[len(pwMsgs)-2], opts: prewarmOptsHash(payload), cancel: cancel, done: make(chan struct{})}
|
||||
end, ok := prewarmBegin(run)
|
||||
if !ok {
|
||||
t.Fatal("prewarmBegin refusé, moteur libre")
|
||||
}
|
||||
done := engineRequestStartKeep(tc.keep())
|
||||
if kept := ctx.Err() == nil; kept != tc.kept {
|
||||
t.Fatalf("%s : gardé=%v, attendu %v", tc.name, kept, tc.kept)
|
||||
}
|
||||
done()
|
||||
end()
|
||||
cancel()
|
||||
}
|
||||
// Une requête en vol interdit d'en démarrer un.
|
||||
busy := engineRequestStart()
|
||||
if _, ok := prewarmBegin(&prewarmRun{cancel: func() {}}); ok {
|
||||
t.Fatal("préchauffage inscrit avec une requête en vol")
|
||||
}
|
||||
busy()
|
||||
if prewarmLive.Load() {
|
||||
t.Fatal("prewarmLive resté levé")
|
||||
}
|
||||
}
|
||||
|
||||
// turnViewDry ne touche à rien (instantané, mises à jour, nettoyage) et rend,
|
||||
// jusqu'au dernier message, ce que turnView enverra.
|
||||
func TestTurnViewDryNeTouchePas(t *testing.T) {
|
||||
for _, on := range []bool{false, true} {
|
||||
m := projSnapSetup(t, on)
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
playTurn(t, m, c, caps, "bonjour")
|
||||
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !on {
|
||||
// Reste d'une époque avec la clé : turnView le nettoierait.
|
||||
c.mu.Lock()
|
||||
c.ProjSnap = &projSnapshot{Key: "x"}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
c.mu.Lock()
|
||||
before, _ := json.Marshal(c)
|
||||
hist := append([]Message(nil), c.Messages...)
|
||||
c.mu.Unlock()
|
||||
|
||||
dry, _ := c.turnViewDry(caps, append(append([]Message(nil), hist...), um(prewarmSentinel)), true)
|
||||
c.mu.Lock()
|
||||
after, _ := json.Marshal(c)
|
||||
c.mu.Unlock()
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatalf("on=%v : turnViewDry a modifié la discussion", on)
|
||||
}
|
||||
sent, _, _ := c.turnView(caps, c.epoch, append(append([]Message(nil), hist...), um("suite")), true)
|
||||
dw, sw := normalizeSystemMessages(dry), normalizeSystemMessages(sent)
|
||||
if len(dw) != len(sw) || !reflect.DeepEqual(jsonValue(t, dw[:len(dw)-1]), jsonValue(t, sw[:len(sw)-1])) {
|
||||
t.Fatalf("on=%v : préfixe de turnViewDry ≠ turnView\n%v\n%v", on, jsonValue(t, dw), jsonValue(t, sw))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// oldChatPayload : la construction du corps dans runChat avant buildChatPayload,
|
||||
// recopiée telle quelle.
|
||||
func oldChatPayload(ep chatEndpoint, messages []Message, temperature float64, tools []Tool, disableTools, toolChoiceNone, echoOff bool, effort string, kwargs map[string]any) map[string]any {
|
||||
sent := normalizeSystemMessages(messages)
|
||||
if toolChoiceNone {
|
||||
sent = withTrailingHint(sent, toolsOffHint)
|
||||
}
|
||||
pol := currentEchoPolicy()
|
||||
if ep.External || echoOff {
|
||||
pol = echoPolicy{}
|
||||
}
|
||||
sent, _ = echoMessages(sent, pol)
|
||||
payload := map[string]any{
|
||||
"model": ep.Model,
|
||||
"messages": expandImageRefs(sent),
|
||||
"stream": true,
|
||||
"temperature": temperature,
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
}
|
||||
applySampling(payload)
|
||||
if effort != "" {
|
||||
payload["reasoning_effort"] = effort
|
||||
}
|
||||
if kwargs != nil && !ep.External {
|
||||
payload["chat_template_kwargs"] = kwargs
|
||||
}
|
||||
if len(tools) > 0 && !disableTools {
|
||||
payload["tools"] = tools
|
||||
payload["parallel_tool_calls"] = false
|
||||
if toolChoiceNone {
|
||||
payload["tool_choice"] = "none"
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// oldTurnReasoning : le calcul du raisonnement dans runChat d'avant, recopié.
|
||||
func oldTurnReasoning(cfg map[string]string) (string, string, bool, map[string]any) {
|
||||
effortWanted := reasoningEffortValue(cfg["REASONING_EFFORT"])
|
||||
reasoningEffort := effortResolve(effortWanted)
|
||||
thinkOff := reasoningExplicitlyOff(cfg["REASONING"]) || effortWanted == "none"
|
||||
return effortWanted, reasoningEffort, thinkOff, reasoningTemplateKwargs(thinkOff, reasoningEffort)
|
||||
}
|
||||
|
||||
// Refactorisation : le corps construit par wireMessages + buildChatPayload est,
|
||||
// octet pour octet, celui d'avant — sur toute la matrice des options d'une
|
||||
// requête (outils coupés ou neutralisés, intensité, gabarit, API externe,
|
||||
// raisonnement renvoyé, images).
|
||||
func TestBuildChatPayloadIdentiqueAvant(t *testing.T) {
|
||||
m := prewarmSetup(t, map[string]string{"TOP_P": "0.9", "TOP_K": "20", "REASONING_ECHO": "on"})
|
||||
_ = m
|
||||
tools := EnabledTools(Caps{Agent: true, Mem: MemAlways})
|
||||
if len(tools) == 0 {
|
||||
t.Fatal("aucun outil pour la matrice")
|
||||
}
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: "sys"},
|
||||
projectContextMessageForTest(),
|
||||
um("bonjour"),
|
||||
{Role: "assistant", Content: "salut", ReasoningContent: "je pense", ReasoningModel: echoTestModel},
|
||||
{Role: "user", Content: []any{map[string]any{"type": "text", "text": "et ça ?"}, map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,AAAA"}}}},
|
||||
}
|
||||
eps := []chatEndpoint{{URL: "http://localhost:1/v1/chat/completions", Model: "loki"}, {URL: "https://api.example/v1/chat/completions", Model: "gpt", External: true}}
|
||||
for _, effortCfg := range []string{"", "high", "none"} {
|
||||
cfg := map[string]string{"REASONING_EFFORT": effortCfg, "REASONING": "on"}
|
||||
w1, e1, o1, k1 := oldTurnReasoning(cfg)
|
||||
w2, e2, o2, k2 := turnReasoning(cfg)
|
||||
if w1 != w2 || e1 != e2 || o1 != o2 || !reflect.DeepEqual(k1, k2) {
|
||||
t.Fatalf("turnReasoning(%q) différent d'avant", effortCfg)
|
||||
}
|
||||
for _, ep := range eps {
|
||||
for _, tl := range [][]Tool{nil, tools} {
|
||||
for _, disable := range []bool{false, true} {
|
||||
for _, none := range []bool{false, true} {
|
||||
for _, echoOff := range []bool{false, true} {
|
||||
want := oldChatPayload(ep, msgs, 0.7, tl, disable, none, echoOff, e2, k2)
|
||||
sent, _ := wireMessages(msgs, none, turnEchoPolicy(ep.External || echoOff))
|
||||
got := buildChatPayload(ep, sent, 0.7, chatPayloadOpts{tools: tl, disableTools: disable, toolChoiceNone: none, effort: e2, kwargs: k2})
|
||||
wb, _ := json.Marshal(want)
|
||||
gb, _ := json.Marshal(got)
|
||||
if !bytes.Equal(wb, gb) {
|
||||
t.Fatalf("corps différent (effort=%q ext=%v outils=%d off=%v none=%v echoOff=%v)\navant %s\naprès %s",
|
||||
effortCfg, ep.External, len(tl), disable, none, echoOff, wb, gb)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// projectContextMessageForTest : un message projet, que normalizeSystemMessages
|
||||
// déplace en tête du premier message utilisateur.
|
||||
func projectContextMessageForTest() Message {
|
||||
return renderProjectContext("Projet de test")
|
||||
}
|
||||
|
||||
// Refactorisation, de bout en bout : la requête d'un tour joué par generate est
|
||||
// celle de l'assemblage et du corps d'avant, pour chaque combinaison de modes.
|
||||
func TestGenerateCorpsIdentiqueAvant(t *testing.T) {
|
||||
m := prewarmSetup(t, map[string]string{"REASONING_EFFORT": "high", "TOP_P": "0.9"})
|
||||
matrix := []Caps{
|
||||
{},
|
||||
{Agent: true},
|
||||
{Agent: true, Mem: MemAlways},
|
||||
{Agent: true, Mem: MemOnDemand, Internet: true},
|
||||
{Agent: true, Code: true, Role: "builder"},
|
||||
{Agent: true, Code: true, Role: "planner"},
|
||||
}
|
||||
for i, caps := range matrix {
|
||||
c := newTestConv()
|
||||
c.mu.Lock()
|
||||
c.Messages = []Message{um("bonjour"), am("salut")}
|
||||
history := append(append([]Message(nil), c.Messages...), um("suite"))
|
||||
c.mu.Unlock()
|
||||
_, effort, _, kwargs := oldTurnReasoning(ReadConfig())
|
||||
final := oldAssembly(caps, history) // normalisé
|
||||
want := oldChatPayload(resolveChatEndpoint(), final, 0.7, EnabledTools(caps), false, false, false, effort, kwargs)
|
||||
before := len(m.completions())
|
||||
prewarmTurn(t, c, caps, "suite")
|
||||
comps := m.completions()
|
||||
if len(comps) <= before {
|
||||
t.Fatalf("cas %d : aucune requête", i)
|
||||
}
|
||||
var got any
|
||||
if err := json.Unmarshal([]byte(comps[before]), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, jsonValue(t, want)) {
|
||||
t.Fatalf("cas %d (%+v) : corps différent d'avant\nreçu %v\nattendu %v", i, caps, got, jsonValue(t, want))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrewarmNearMidnight(t *testing.T) {
|
||||
loc := time.Local
|
||||
if !prewarmNearMidnight(time.Date(2026, 10, 4, 23, 55, 0, 0, loc)) {
|
||||
t.Fatal("23:55 doit s'abstenir")
|
||||
}
|
||||
if prewarmNearMidnight(time.Date(2026, 10, 4, 23, 45, 0, 0, loc)) || prewarmNearMidnight(time.Date(2026, 10, 4, 0, 5, 0, 0, loc)) {
|
||||
t.Fatal("23:45 et 00:05 doivent préchauffer")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
package loki
|
||||
|
||||
// chat_projsnap.go — PROJ_SNAPSHOT : le bloc projet FIGÉ par discussion, et ses
|
||||
// changements livrés en <context_update> dans le message suivant.
|
||||
//
|
||||
// Pourquoi. Le contexte du projet (description, index mémoire, trackers,
|
||||
// consignes du dépôt) part en tête du PREMIER message utilisateur
|
||||
// (normalizeSystemMessages). Reconstruit à chaque tour, il change dès qu'une page
|
||||
// est créée ou qu'un tracker reçoit un point — et tout ce qui le suit dans le
|
||||
// prompt est recalculé : 10 à 45k tokens, des dizaines de secondes sur un 27B,
|
||||
// des minutes sur un MoE déchargé. Un modèle qui note une valeur à chaque tour
|
||||
// paie ce prix à chaque tour.
|
||||
//
|
||||
// Avec la clé (off par défaut) :
|
||||
// - la discussion garde une copie figée du bloc (ProjSnap), envoyée à l'octet
|
||||
// près d'un tour à l'autre. Ses en-têtes disent « à jour au <date> » et que
|
||||
// les <context_update> de Loki les complètent ;
|
||||
// - un changement arrive en tête du NOUVEAU message utilisateur, rangé tel quel
|
||||
// dans l'historique (le tour suivant renvoie les mêmes octets). Le moteur
|
||||
// pose un point de reprise au début de chaque message utilisateur : seul ce
|
||||
// petit bloc est à calculer ;
|
||||
// - rien n'est perdu : lignes ajoutées, modifiées, retirées pour les données
|
||||
// faites de lignes (pages, trackers), texte COMPLET pour la prose
|
||||
// (description, AGENTS.md). Une ligne fixe du système dit que ces blocs
|
||||
// viennent de Loki et que le plus récent l'emporte.
|
||||
//
|
||||
// Le bloc est rafraîchi — copie neuve, blocs <context_update> retirés de tout
|
||||
// l'historique — seulement quand le début du prompt change de toute façon :
|
||||
// compaction (début, fin de tour, manuelle, en cours de tour, réduction forcée),
|
||||
// système ou outils modifiés (date du jour, réglages, mode), cache froid
|
||||
// (redémarrage de Loki, changement de modèle, de preset ou de réglage moteur),
|
||||
// projet, nom, mode mémoire, mode Code ou dépôt changés, textes des en-têtes
|
||||
// changés. Et quand les mises à jour accumulées dépassent un seuil : au-delà, le
|
||||
// modèle aurait trop à recoller.
|
||||
//
|
||||
// Hors discussion : une tâche planifiée garde le bloc vivant (elle repart de zéro
|
||||
// à chaque fois), et sans agent le modèle brut ne reçoit rien du projet — ni bloc,
|
||||
// ni mise à jour.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// projSnapEnabled : la clé PROJ_SNAPSHOT demande-t-elle le bloc figé ? Jamais
|
||||
// pour un preset externe (API OpenAI-compatible) : ses requêtes restent celles
|
||||
// d'avant, quel que soit le réglage.
|
||||
func projSnapEnabled(cfg map[string]string) bool {
|
||||
if isExternalConfig(cfg) {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(cfg["PROJ_SNAPSHOT"])) {
|
||||
case "on", "1", "true", "yes", "oui":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Textes propres au bloc figé. Leur empreinte entre dans la clé de validité :
|
||||
// une mise à jour de Loki qui les reformule rafraîchit les instantanés persistés.
|
||||
const (
|
||||
projSnapFormat = "1"
|
||||
memIndexFrozenTail = " Later <context_update> blocks from Loki update it."
|
||||
trackerFrozenHead = " — your trackers, each with its latest point at that time; later <context_update> blocks from Loki replace these lines. " +
|
||||
"To say which trackers exist or give a latest value, use this list with those updates — do NOT call tracker with no arguments to list them. " +
|
||||
"Call tracker(name[, when]) only to look further back in history, or with action \"add\"/\"edit\"/\"delete\" to change data.\n"
|
||||
ctxUpdOpen = `<context_update from="loki"`
|
||||
ctxUpdClose = "</context_update>"
|
||||
// projSnapSystemLine : la ligne fixe du prompt système, clé posée seulement.
|
||||
projSnapSystemLine = "A <context_update from=\"loki\"> block at the start of a user message is written by Loki, not by the user: it updates <project_context>, and the most recent one wins.\n"
|
||||
)
|
||||
|
||||
// projSnapBoot : identifiant de ce lancement de Loki. Après un redémarrage, le
|
||||
// cache du moteur est froid : garder un vieil instantané n'y gagnerait rien.
|
||||
var projSnapBoot = strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
|
||||
// projSnapNow : l'horodatage « à jour au » des en-têtes et des mises à jour.
|
||||
func projSnapNow() string { return time.Now().Format("2006-01-02 15:04") }
|
||||
|
||||
// projState : ce que le modèle sait du projet, section par section — au moment
|
||||
// de l'instantané, puis après chaque mise à jour annoncée. Structuré, jamais un
|
||||
// simple hash : c'est contre lui qu'on calcule la mise à jour suivante, y
|
||||
// compris après un redémarrage.
|
||||
type projState struct {
|
||||
Code string `json:"code,omitempty"` // consignes du dépôt, message complet
|
||||
Desc string `json:"desc,omitempty"` // description du projet
|
||||
MemOn bool `json:"mem_on,omitempty"` // index mémoire présent
|
||||
MemProse string `json:"mem_prose,omitempty"` // lignes de l'index qui ne sont pas des pages
|
||||
Mem map[string]string `json:"mem,omitempty"` // fichier → ligne de page
|
||||
Trk map[string]string `json:"trk,omitempty"` // slug → « nom — valeur (date) »
|
||||
}
|
||||
|
||||
// projSnapshot : le bloc figé d'une discussion. Persisté avec elle (omitempty :
|
||||
// absent tant que la clé n'a jamais servi, et ignoré d'un Loki plus ancien).
|
||||
type projSnapshot struct {
|
||||
At int64 `json:"at"` // instant de l'instantané (ms)
|
||||
Msgs []Message `json:"msgs,omitempty"` // le bloc tel qu'il part, à l'octet près
|
||||
State projState `json:"state"` // dernier état annoncé au modèle
|
||||
Key string `json:"key"` // validité : lancement, réglages, projet, mode…
|
||||
SysHash string `json:"sys_hash"` // système + outils du dernier tour
|
||||
SnapTok int `json:"snap_tok,omitempty"`
|
||||
DeltaTok int `json:"delta_tok,omitempty"` // mises à jour livrées depuis
|
||||
}
|
||||
|
||||
// projLive : le bloc vivant, rendu au format figé, avec son état. memOrder /
|
||||
// trkOrder : ordre d'affichage, pour des mises à jour stables.
|
||||
type projLive struct {
|
||||
msgs []Message
|
||||
state projState
|
||||
memMsg Message
|
||||
trkMsg Message
|
||||
memOrder []string
|
||||
trkOrder []string
|
||||
}
|
||||
|
||||
// projSnapCapture lit le contexte projet UNE fois et en tire le bloc (même
|
||||
// ordre que l'assemblage d'un tour : consignes du dépôt, description, index
|
||||
// mémoire, trackers) et son état.
|
||||
func projSnapCapture(caps Caps, asOf string) projLive {
|
||||
var lv projLive
|
||||
if m, ok := codeInstructionsMessage(caps); ok {
|
||||
lv.msgs = append(lv.msgs, m)
|
||||
lv.state.Code, _ = m.Content.(string)
|
||||
}
|
||||
if desc := strings.TrimSpace(projectDesc(activeProjectSlug())); desc != "" {
|
||||
lv.msgs = append(lv.msgs, renderProjectContext(desc))
|
||||
lv.state.Desc = desc
|
||||
}
|
||||
if idx, ok := memIndexText(); ok {
|
||||
lv.memMsg = renderMemIndex(idx, asOf)
|
||||
lv.msgs = append(lv.msgs, lv.memMsg)
|
||||
lv.state.MemOn = true
|
||||
lv.state.MemProse, lv.state.Mem, lv.memOrder = splitMemIndex(idx)
|
||||
}
|
||||
if list, ok := trackerIndexList(); ok {
|
||||
lv.trkMsg = renderTrackerIndex(list, asOf)
|
||||
lv.msgs = append(lv.msgs, lv.trkMsg)
|
||||
lv.state.Trk = map[string]string{}
|
||||
for _, m := range list {
|
||||
lv.state.Trk[m.Slug] = trackerIndexLine(m)
|
||||
lv.trkOrder = append(lv.trkOrder, m.Slug)
|
||||
}
|
||||
}
|
||||
return lv
|
||||
}
|
||||
|
||||
// splitMemIndex sépare les lignes de page (clé = fichier entre « ]( » et « ) »)
|
||||
// du reste de l'index. Une ligne de page en double part avec la prose : ainsi
|
||||
// tout changement la concernant fait renvoyer l'index entier, sans perte.
|
||||
func splitMemIndex(idx string) (prose string, pages map[string]string, order []string) {
|
||||
pages = map[string]string{}
|
||||
var other []string
|
||||
for _, line := range strings.Split(idx, "\n") {
|
||||
l := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(l, "- [") {
|
||||
if i := strings.Index(l, "]("); i >= 0 {
|
||||
if j := strings.Index(l[i+2:], ")"); j > 0 {
|
||||
key := l[i+2 : i+2+j]
|
||||
if _, dup := pages[key]; !dup {
|
||||
pages[key] = l
|
||||
order = append(order, key)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
other = append(other, line)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(other, "\n")), pages, order
|
||||
}
|
||||
|
||||
func newProjSnapshot(lv projLive, key, sysHash string) *projSnapshot {
|
||||
tok := 0
|
||||
for _, m := range lv.msgs {
|
||||
tok += len(msgText(m)) / 4
|
||||
}
|
||||
return &projSnapshot{At: time.Now().UnixMilli(), Msgs: lv.msgs, State: lv.state, Key: key, SysHash: sysHash, SnapTok: tok}
|
||||
}
|
||||
|
||||
// projSnapDeltaLimit : au-delà de ce volume de mises à jour depuis l'instantané
|
||||
// (en tokens estimés), on rafraîchit plutôt que d'en empiler d'autres : environ
|
||||
// un quart du bloc, entre 400 et 1500 tokens.
|
||||
func projSnapDeltaLimit(snapTok int) int {
|
||||
return min(1500, max(400, snapTok/4))
|
||||
}
|
||||
|
||||
// projSnapKey : tout ce qui, en changeant, rend l'instantané faux ou inutile.
|
||||
// Les réglages pris en compte sont ceux qui touchent au moteur (modèle, preset,
|
||||
// arguments : un changement le relance, le cache est froid) ; les boutons de
|
||||
// conversation qui ne le relancent pas en sont exclus.
|
||||
func projSnapKey(caps Caps, cfg map[string]string) string {
|
||||
h := fnv.New64a()
|
||||
w := func(s string) {
|
||||
h.Write([]byte(s))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
w(projSnapFormat)
|
||||
w(projSnapBoot)
|
||||
keys := make([]string, 0, len(cfg))
|
||||
for k := range cfg {
|
||||
if !projSnapIgnoredKeys[k] {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
w(k)
|
||||
w(cfg[k])
|
||||
}
|
||||
slug := activeProjectSlug()
|
||||
w(slug)
|
||||
w(projectName(slug))
|
||||
w(string(memMode()))
|
||||
w(strconv.FormatBool(caps.Code))
|
||||
if caps.Code {
|
||||
d, _ := gitRepoDir("")
|
||||
w(d)
|
||||
w(agentCwd())
|
||||
}
|
||||
for _, s := range []string{memIndexFrozenTail, trackerFrozenHead, ctxUpdOpen, ctxUpdClose, projSnapSystemLine} {
|
||||
w(s)
|
||||
}
|
||||
return fmt.Sprintf("%016x", h.Sum64())
|
||||
}
|
||||
|
||||
// projSnapIgnoredKeys : réglages changés en cours de discussion sans relancer le
|
||||
// moteur ni toucher au bloc. Les compter rafraîchirait pour rien.
|
||||
var projSnapIgnoredKeys = map[string]bool{
|
||||
"PROJ_SNAPSHOT": true, "PREWARM": true, "REASONING_ECHO": true, "REASONING_EFFORT": true, "TEMP": true,
|
||||
"COMPACT": true, "COMPACT_CONTINUATION": true, "CRAWL4AI_URL": true, "CRAWL4AI_KEY": true, "WEB_ENGINE": true,
|
||||
"KEEP_TURN_IMAGES": true, "NUDGE_IN_TOOL": true,
|
||||
}
|
||||
|
||||
// projSnapSysHash : empreinte du bloc système commun et des outils. S'ils
|
||||
// changent, tout le prompt est recalculé de toute façon : rafraîchir est gratuit.
|
||||
func projSnapSysHash(sent []Message, tools []Tool) string {
|
||||
h := fnv.New64a()
|
||||
for _, m := range sent {
|
||||
if m.Role != "system" {
|
||||
break
|
||||
}
|
||||
if isProjectSystem(m) {
|
||||
continue
|
||||
}
|
||||
s, _ := m.Content.(string)
|
||||
h.Write([]byte(s))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
b, _ := json.Marshal(tools)
|
||||
h.Write(b)
|
||||
return fmt.Sprintf("%016x", h.Sum64())
|
||||
}
|
||||
|
||||
// projDelta : la mise à jour à annoncer entre l'état déjà connu du modèle et le
|
||||
// bloc vivant. "" = rien de changé. Les sections faites de lignes changent ligne
|
||||
// à ligne (une ligne de tracker remplace la précédente) ; la prose, ou une
|
||||
// section qui apparaît, repart en entier.
|
||||
func projDelta(old projState, lv projLive, asOf string) string {
|
||||
cur := lv.state
|
||||
var b strings.Builder
|
||||
full := func(m Message) {
|
||||
b.WriteString("Current version, replaces any earlier one — " + msgText(m) + "\n")
|
||||
}
|
||||
if old.Code != cur.Code {
|
||||
if cur.Code == "" {
|
||||
b.WriteString("Repository instructions: removed.\n")
|
||||
} else {
|
||||
full(Message{Content: cur.Code})
|
||||
}
|
||||
}
|
||||
if old.Desc != cur.Desc {
|
||||
if cur.Desc == "" {
|
||||
b.WriteString("Project description: removed.\n")
|
||||
} else {
|
||||
full(renderProjectContext(cur.Desc))
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !cur.MemOn && old.MemOn:
|
||||
b.WriteString("Memory index: no pages any more.\n")
|
||||
case cur.MemOn && (!old.MemOn || old.MemProse != cur.MemProse):
|
||||
full(lv.memMsg)
|
||||
case cur.MemOn:
|
||||
var lines []string
|
||||
for _, k := range lv.memOrder {
|
||||
l := strings.TrimPrefix(cur.Mem[k], "- ")
|
||||
switch prev, ok := old.Mem[k]; {
|
||||
case !ok:
|
||||
lines = append(lines, "+ "+l)
|
||||
case prev != cur.Mem[k]:
|
||||
lines = append(lines, "~ "+l)
|
||||
}
|
||||
}
|
||||
for _, k := range sortedMissing(old.Mem, cur.Mem) {
|
||||
lines = append(lines, "- "+k+" (deleted)")
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
b.WriteString("Memory index changes:\n" + strings.Join(lines, "\n") + "\n")
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(cur.Trk) == 0 && len(old.Trk) > 0:
|
||||
b.WriteString("Trackers: none left.\n")
|
||||
case len(cur.Trk) > 0 && len(old.Trk) == 0:
|
||||
full(lv.trkMsg)
|
||||
case len(cur.Trk) > 0:
|
||||
var lines []string
|
||||
for _, k := range lv.trkOrder {
|
||||
switch prev, ok := old.Trk[k]; {
|
||||
case !ok:
|
||||
lines = append(lines, "+ "+cur.Trk[k])
|
||||
case prev != cur.Trk[k]:
|
||||
lines = append(lines, "~ "+cur.Trk[k])
|
||||
}
|
||||
}
|
||||
for _, k := range sortedMissing(old.Trk, cur.Trk) {
|
||||
lines = append(lines, "- "+old.Trk[k]+" (deleted)")
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
b.WriteString("Tracker changes (latest point):\n" + strings.Join(lines, "\n") + "\n")
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
// Un titre de page qui contiendrait la balise fermante couperait le bloc au
|
||||
// mauvais endroit au moment de le retirer.
|
||||
body := strings.ReplaceAll(b.String(), ctxUpdClose, "</context-update>")
|
||||
return ctxUpdOpen + ` at="` + asOf + `">` + "\n" + body + ctxUpdClose + "\n\n"
|
||||
}
|
||||
|
||||
// sortedMissing : clés de old absentes de cur, triées.
|
||||
func sortedMissing(old, cur map[string]string) []string {
|
||||
var out []string
|
||||
for k := range old {
|
||||
if _, ok := cur[k]; !ok {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// --- blocs <context_update> dans l'historique -----------------------------
|
||||
|
||||
// stripCtxUpdText retire le(s) bloc(s) posé(s) en TÊTE du texte. Seulement en
|
||||
// tête : un message qui en parle plus loin reste intact.
|
||||
func stripCtxUpdText(s string) (string, bool) {
|
||||
stripped := false
|
||||
for strings.HasPrefix(s, ctxUpdOpen) {
|
||||
i := strings.Index(s, ctxUpdClose)
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
s = strings.TrimPrefix(s[i+len(ctxUpdClose):], "\n\n")
|
||||
stripped = true
|
||||
}
|
||||
return s, stripped
|
||||
}
|
||||
|
||||
// withoutCtxUpdate : le message sans son bloc de tête, ok=false s'il n'en avait
|
||||
// pas (message rendu tel quel). Contenu texte ou multimodal ([]any, []map).
|
||||
// Seulement un message que Loki a marqué (CtxUpd) : sans la clé, aucun ne
|
||||
// l'est, et un texte tapé par l'utilisateur n'est jamais retouché.
|
||||
func withoutCtxUpdate(m Message) (Message, bool) {
|
||||
if m.Role != "user" || !m.CtxUpd {
|
||||
return m, false
|
||||
}
|
||||
m, ok := withoutCtxUpdateText(m)
|
||||
if ok {
|
||||
m.CtxUpd = false
|
||||
}
|
||||
return m, ok
|
||||
}
|
||||
|
||||
func withoutCtxUpdateText(m Message) (Message, bool) {
|
||||
switch c := m.Content.(type) {
|
||||
case string:
|
||||
if s, ok := stripCtxUpdText(c); ok {
|
||||
m.Content = s
|
||||
return m, true
|
||||
}
|
||||
case []any:
|
||||
if len(c) > 0 {
|
||||
if p, ok := c[0].(map[string]any); ok && p["type"] == "text" {
|
||||
if t, _ := p["text"].(string); t != "" {
|
||||
if s, ok := stripCtxUpdText(t); ok {
|
||||
out := append([]any(nil), c[1:]...)
|
||||
if s != "" {
|
||||
q := map[string]any{}
|
||||
for k, v := range p {
|
||||
q[k] = v
|
||||
}
|
||||
q["text"] = s
|
||||
out = append([]any{q}, out...)
|
||||
}
|
||||
m.Content = out
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case []map[string]any:
|
||||
if len(c) > 0 && c[0]["type"] == "text" {
|
||||
if t, _ := c[0]["text"].(string); t != "" {
|
||||
if s, ok := stripCtxUpdText(t); ok {
|
||||
out := append([]map[string]any(nil), c[1:]...)
|
||||
if s != "" {
|
||||
q := map[string]any{}
|
||||
for k, v := range c[0] {
|
||||
q[k] = v
|
||||
}
|
||||
q["text"] = s
|
||||
out = append([]map[string]any{q}, out...)
|
||||
}
|
||||
m.Content = out
|
||||
return m, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, false
|
||||
}
|
||||
|
||||
// stripContextUpdates rend l'historique sans aucun bloc <context_update>. Sans
|
||||
// bloc, c'est la MÊME tranche (rien recopié) : le cas de toutes les discussions
|
||||
// sans la clé.
|
||||
func stripContextUpdates(msgs []Message) []Message {
|
||||
var out []Message
|
||||
for i, m := range msgs {
|
||||
if s, ok := withoutCtxUpdate(m); ok {
|
||||
if out == nil {
|
||||
out = append([]Message(nil), msgs...)
|
||||
}
|
||||
out[i] = s
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
return msgs
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasContextUpdates(msgs []Message) bool {
|
||||
for _, m := range msgs {
|
||||
if _, ok := withoutCtxUpdate(m); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// prependCtxUpdate pose le bloc en tête d'un message utilisateur, comme
|
||||
// prependToFirstUser : texte simple, ou une partie texte de plus en multimodal.
|
||||
func prependCtxUpdate(m Message, block string) (Message, bool) {
|
||||
if m.Role != "user" {
|
||||
return m, false
|
||||
}
|
||||
switch c := m.Content.(type) {
|
||||
case string:
|
||||
m.Content = block + c
|
||||
case []any:
|
||||
m.Content = append([]any{map[string]any{"type": "text", "text": block}}, c...)
|
||||
case []map[string]any:
|
||||
m.Content = append([]map[string]any{{"type": "text", "text": block}}, c...)
|
||||
default:
|
||||
return m, false
|
||||
}
|
||||
m.CtxUpd = true
|
||||
return m, true
|
||||
}
|
||||
|
||||
// replaceProjectHead remplace le bloc projet de tête (hors rappel des pages
|
||||
// lues, qui appartient à l'historique) par block, à la même place.
|
||||
func replaceProjectHead(msgs []Message, block []Message) []Message {
|
||||
end := 0
|
||||
for end < len(msgs) && msgs[end].Role == "system" {
|
||||
end++
|
||||
}
|
||||
out := make([]Message, 0, len(msgs)+len(block))
|
||||
inserted := false
|
||||
for _, m := range msgs[:end] {
|
||||
reminder := false
|
||||
if s, ok := m.Content.(string); ok && strings.HasPrefix(s, memReminderPrefix) {
|
||||
reminder = true
|
||||
}
|
||||
if isProjectSystem(m) && !reminder {
|
||||
if !inserted {
|
||||
out = append(out, block...)
|
||||
inserted = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if reminder && !inserted {
|
||||
out = append(out, block...)
|
||||
inserted = true
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
if !inserted {
|
||||
out = append(out, block...)
|
||||
}
|
||||
return append(out, msgs[end:]...)
|
||||
}
|
||||
|
||||
// --- assemblage d'un tour de la discussion --------------------------------
|
||||
|
||||
// turnView assemble la séquence envoyée pour un tour de la discussion (generate,
|
||||
// runBuilderTurn) : prompt du preset, consignes du dépôt, contexte projet, puis
|
||||
// l'historique. inject=false : modèle brut (agent off), rien du projet.
|
||||
//
|
||||
// Sans PROJ_SNAPSHOT : exactement l'assemblage d'avant, contexte vivant. Seul
|
||||
// ajout, sans effet tant que la clé n'a jamais servi : une discussion qui porte
|
||||
// encore un instantané ou des blocs d'une époque où la clé était posée en est
|
||||
// nettoyée — un vieux <context_update> placé après le bloc vivant se lirait
|
||||
// comme plus récent que lui.
|
||||
//
|
||||
// Avec la clé : instantané pris, gardé ou rafraîchi ; mise à jour éventuelle
|
||||
// posée en tête du dernier message utilisateur, dans c.Messages ET dans msgs
|
||||
// (sous c.mu, si l'epoch n'a pas changé). pt sert au rafraîchissement en cours
|
||||
// de tour (runChatTools) ; nil sans la clé.
|
||||
func (c *Conversation) turnView(caps Caps, epoch int, msgs []Message, inject bool) (sent []Message, tools []Tool, pt *projSnapTurn) {
|
||||
return c.assembleTurn(caps, epoch, msgs, inject, false)
|
||||
}
|
||||
|
||||
// turnViewDry : la séquence que turnView enverrait pour msgs, SANS rien toucher
|
||||
// à la conversation — ni instantané pris, ni mise à jour rangée, ni nettoyage.
|
||||
// Pour le préchauffage (chat_prewarm.go) : contenu vivant, mêmes décisions que
|
||||
// le vrai tour. La mise à jour qu'il poserait en tête du dernier message n'y est
|
||||
// pas : ce message-là est la sentinelle, hors du préfixe préchauffé.
|
||||
func (c *Conversation) turnViewDry(caps Caps, msgs []Message, inject bool) ([]Message, []Tool) {
|
||||
sent, tools, _ := c.assembleTurn(caps, 0, msgs, inject, true)
|
||||
return sent, tools
|
||||
}
|
||||
|
||||
func (c *Conversation) assembleTurn(caps Caps, epoch int, msgs []Message, inject, dry bool) (sent []Message, tools []Tool, pt *projSnapTurn) {
|
||||
cfg := ReadConfig()
|
||||
on := projSnapEnabled(cfg)
|
||||
if !inject {
|
||||
// Sans agent : ni bloc, ni mise à jour. Des blocs restés d'un tour avec
|
||||
// agent sont retirés de la vue envoyée (l'historique les garde : l'agent
|
||||
// revenu, le système change et le bloc est rafraîchi).
|
||||
sent, tools = prepareTurn(stripContextUpdates(msgs), caps)
|
||||
return sent, tools, nil
|
||||
}
|
||||
if !on {
|
||||
c.mu.Lock()
|
||||
if !dry && c.epoch == epoch && (c.ProjSnap != nil || hasContextUpdates(c.Messages)) {
|
||||
c.ProjSnap = nil
|
||||
c.Messages = stripContextUpdates(c.Messages)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
msgs = stripContextUpdates(msgs)
|
||||
final := append(projectSystemMessages(), msgs...)
|
||||
if m, ok := codeInstructionsMessage(caps); ok {
|
||||
final = append([]Message{m}, final...)
|
||||
}
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, final...)
|
||||
}
|
||||
sent, tools = prepareTurn(final, caps)
|
||||
return sent, tools, nil
|
||||
}
|
||||
|
||||
// Lectures (disque, base, git) hors du verrou de la conversation.
|
||||
asOf := projSnapNow()
|
||||
live := projSnapCapture(caps, asOf)
|
||||
key := projSnapKey(caps, cfg)
|
||||
// Même calcul que prepareTurn, en deux temps : l'empreinte du système et des
|
||||
// outils décide du rafraîchissement avant l'assemblage. Les outils ne sont
|
||||
// calculés qu'une fois (connexions MCP).
|
||||
tools = EnabledTools(caps)
|
||||
var spMsgs []Message
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
spMsgs = []Message{{Role: "system", Content: sp}}
|
||||
}
|
||||
sysHash := projSnapSysHash(InjectSkills(spMsgs, caps, tools), tools)
|
||||
|
||||
block := live.msgs
|
||||
c.mu.Lock()
|
||||
if dry {
|
||||
// Même décision que projSnapApplyLocked, appliquée à la seule copie.
|
||||
if refresh, _, _ := projSnapDecide(c.ProjSnap, live, key, sysHash, asOf); refresh {
|
||||
msgs = stripContextUpdates(msgs)
|
||||
} else {
|
||||
block = c.ProjSnap.Msgs
|
||||
}
|
||||
c.mu.Unlock()
|
||||
final := append(append(append([]Message(nil), spMsgs...), block...), msgs...)
|
||||
return InjectSkills(final, caps, tools), tools, nil
|
||||
}
|
||||
if c.epoch == epoch {
|
||||
refresh, delta := c.projSnapApplyLocked(live, key, sysHash, asOf)
|
||||
if refresh {
|
||||
msgs = stripContextUpdates(msgs)
|
||||
}
|
||||
if delta != "" {
|
||||
if n := len(msgs); n > 0 {
|
||||
if m, ok := prependCtxUpdate(msgs[n-1], delta); ok {
|
||||
msgs = append(msgs[:n-1:n-1], m)
|
||||
}
|
||||
}
|
||||
}
|
||||
block = c.ProjSnap.Msgs
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
final := append(append(append([]Message(nil), spMsgs...), block...), msgs...)
|
||||
sent = InjectSkills(final, caps, tools)
|
||||
return sent, tools, &projSnapTurn{caps: caps, key: key, sysHash: sysHash}
|
||||
}
|
||||
|
||||
// projSnapApplyLocked décide pour ce tour, c.mu tenu : rafraîchir (instantané
|
||||
// neuf, blocs retirés de l'historique) ou annoncer une mise à jour, posée en tête
|
||||
// du dernier message utilisateur de c.Messages. Renvoie ce qui a été fait, pour
|
||||
// que l'appelant l'applique à sa propre copie.
|
||||
func (c *Conversation) projSnapApplyLocked(live projLive, key, sysHash, asOf string) (refresh bool, delta string) {
|
||||
snap := c.ProjSnap
|
||||
refresh, delta, tok := projSnapDecide(snap, live, key, sysHash, asOf)
|
||||
if refresh {
|
||||
c.Messages = stripContextUpdates(c.Messages)
|
||||
c.ProjSnap = newProjSnapshot(live, key, sysHash)
|
||||
return true, ""
|
||||
}
|
||||
if delta == "" {
|
||||
return false, ""
|
||||
}
|
||||
n := len(c.Messages)
|
||||
if n == 0 {
|
||||
return false, ""
|
||||
}
|
||||
m, ok := prependCtxUpdate(c.Messages[n-1], delta)
|
||||
if !ok {
|
||||
// Pas de message utilisateur où la poser (cas théorique) : l'état
|
||||
// annoncé ne bouge pas, elle partira au tour suivant.
|
||||
return false, ""
|
||||
}
|
||||
c.Messages[n-1] = m
|
||||
snap.State = live.state
|
||||
snap.DeltaTok += tok
|
||||
return false, delta
|
||||
}
|
||||
|
||||
// projSnapDecide : rafraîchir ou non, et la mise à jour à annoncer sinon (tok :
|
||||
// sa taille estimée). Pur : projSnapApplyLocked l'applique, le préchauffage
|
||||
// (turnViewDry) s'en sert pour prévoir le tour sans rien toucher.
|
||||
func projSnapDecide(snap *projSnapshot, live projLive, key, sysHash, asOf string) (refresh bool, delta string, tok int) {
|
||||
refresh = snap == nil || snap.Key != key || snap.SysHash != sysHash
|
||||
if !refresh {
|
||||
delta = projDelta(snap.State, live, asOf)
|
||||
tok = len(delta) / 4
|
||||
if delta != "" && snap.DeltaTok+tok > projSnapDeltaLimit(snap.SnapTok) {
|
||||
refresh = true
|
||||
}
|
||||
}
|
||||
return refresh, delta, tok
|
||||
}
|
||||
|
||||
// --- rafraîchissement en cours de tour -------------------------------------
|
||||
|
||||
// projSnapTurn accompagne un tour de discussion avec la clé posée. runChatTools
|
||||
// le récupère dans le contexte et s'en sert quand une compaction ou une
|
||||
// réduction réécrit l'historique en plein tour : le début du prompt change de
|
||||
// toute façon, le bloc est donc remplacé par le vivant (et les mises à jour
|
||||
// retirées), sans perdre celles qu'aurait avalées le résumé. L'appelant range
|
||||
// ensuite refreshed comme nouvel instantané : le tour suivant renvoie les mêmes
|
||||
// octets, pas de recalcul de plus.
|
||||
type projSnapTurn struct {
|
||||
caps Caps
|
||||
key string
|
||||
sysHash string
|
||||
|
||||
mu sync.Mutex
|
||||
refreshed *projSnapshot
|
||||
}
|
||||
|
||||
type projSnapCtxKey struct{}
|
||||
|
||||
func withProjSnapTurn(ctx context.Context, pt *projSnapTurn) context.Context {
|
||||
return context.WithValue(ctx, projSnapCtxKey{}, pt)
|
||||
}
|
||||
|
||||
func projSnapTurnFrom(ctx context.Context) *projSnapTurn {
|
||||
pt, _ := ctx.Value(projSnapCtxKey{}).(*projSnapTurn)
|
||||
return pt
|
||||
}
|
||||
|
||||
// refresh : historique en cours de tour, bloc vivant à la place du figé, sans
|
||||
// aucun <context_update>.
|
||||
func (pt *projSnapTurn) refresh(msgs []Message) []Message {
|
||||
live := projSnapCapture(pt.caps, projSnapNow())
|
||||
out := replaceProjectHead(stripContextUpdates(msgs), live.msgs)
|
||||
pt.mu.Lock()
|
||||
pt.refreshed = newProjSnapshot(live, pt.key, pt.sysHash)
|
||||
pt.mu.Unlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// takeRefreshed : l'instantané posé en cours de tour, nil s'il n'y en a pas eu.
|
||||
func (pt *projSnapTurn) takeRefreshed() *projSnapshot {
|
||||
if pt == nil {
|
||||
return nil
|
||||
}
|
||||
pt.mu.Lock()
|
||||
defer pt.mu.Unlock()
|
||||
s := pt.refreshed
|
||||
pt.refreshed = nil
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// projSnapSetup : base et projet neufs, moteur de comédie qui répond « ok. ».
|
||||
// on = PROJ_SNAPSHOT posée.
|
||||
func projSnapSetup(t *testing.T, on bool) *moteurEcho {
|
||||
t.Helper()
|
||||
withWorkspace(t)
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
setProjectOverride("")
|
||||
t.Cleanup(func() {
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
setProjectOverride("")
|
||||
})
|
||||
ensureDefaultProject()
|
||||
freshTplProbe(t)
|
||||
if err := SetConfigKey("MODEL", "/models/"+echoTestModel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if on {
|
||||
if err := SetConfigKey("PROJ_SNAPSHOT", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m := &moteurEcho{reply: func(int, string) (int, string) {
|
||||
return 200, sseChunk("ok.") + sseFinal("stop", 100, 2)
|
||||
}}
|
||||
m.start(t)
|
||||
return m
|
||||
}
|
||||
|
||||
// playTurn joue un tour utilisateur complet sur c et rend le corps de la PREMIÈRE
|
||||
// requête de ce tour.
|
||||
func playTurn(t *testing.T, m *moteurEcho, c *Conversation, caps Caps, text string) string {
|
||||
t.Helper()
|
||||
before := len(m.all())
|
||||
c.mu.Lock()
|
||||
c.Messages = append(c.Messages, um(text))
|
||||
c.mu.Unlock()
|
||||
c.generate(context.Background(), caps, 0.7, c.epoch)
|
||||
all := m.all()
|
||||
if len(all) <= before {
|
||||
t.Fatalf("aucune requête pour %q", text)
|
||||
}
|
||||
return all[before]
|
||||
}
|
||||
|
||||
// userContents : le texte des messages user d'une requête, dans l'ordre.
|
||||
func userContents(t *testing.T, body string) []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
for _, m := range requestMessages(t, body) {
|
||||
if m["role"] == "user" {
|
||||
s, _ := m["content"].(string)
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func systemContent(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
for _, m := range requestMessages(t, body) {
|
||||
if m["role"] == "system" {
|
||||
s, _ := m["content"].(string)
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// oldAssembly : l'assemblage de generate d'avant PROJ_SNAPSHOT, recopié tel quel.
|
||||
func oldAssembly(caps Caps, msgs []Message) []Message {
|
||||
final := msgs
|
||||
if caps.Agent {
|
||||
final = append(projectSystemMessages(), msgs...)
|
||||
if m, ok := codeInstructionsMessage(caps); ok {
|
||||
final = append([]Message{m}, final...)
|
||||
}
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, final...)
|
||||
}
|
||||
}
|
||||
sent, _ := prepareTurn(final, caps)
|
||||
return normalizeSystemMessages(sent)
|
||||
}
|
||||
|
||||
func jsonValue(t *testing.T, v any) any {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out any
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Clé absente : la requête est celle d'avant, à l'octet près — bloc vivant qui
|
||||
// suit la page créée, aucune ligne système de plus, rien de persisté en plus.
|
||||
func TestProjSnapDefautRienNeChange(t *testing.T) {
|
||||
m := projSnapSetup(t, false)
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
for i, text := range []string{"bonjour", "et ensuite ?"} {
|
||||
if i == 1 {
|
||||
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
c.mu.Lock()
|
||||
want := oldAssembly(caps, append(append([]Message(nil), c.Messages...), um(text)))
|
||||
c.mu.Unlock()
|
||||
body := playTurn(t, m, c, caps, text)
|
||||
var got struct {
|
||||
Messages any `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Messages, jsonValue(t, want)) {
|
||||
t.Fatalf("tour %d : requête différente de l'assemblage d'avant\nreçu %v\nattendu %v", i, got.Messages, jsonValue(t, want))
|
||||
}
|
||||
if strings.Contains(body, "context_update") || strings.Contains(body, "as of") {
|
||||
t.Fatalf("tour %d : trace du bloc figé sans la clé :\n%s", i, body)
|
||||
}
|
||||
}
|
||||
if us := userContents(t, m.all()[1]); !strings.Contains(us[0], "](docker.md)") {
|
||||
t.Fatalf("sans la clé, le bloc vivant doit montrer la nouvelle page : %q", us[0])
|
||||
}
|
||||
b, _ := json.Marshal(c)
|
||||
if c.ProjSnap != nil || strings.Contains(string(b), "proj_snap") {
|
||||
t.Fatalf("instantané persisté sans la clé : %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// La première page d'un projet vide arrive en mise à jour : le premier message
|
||||
// reste identique, la mise à jour est rangée dans l'historique et renvoyée telle
|
||||
// quelle ensuite, la suivante n'annonce que la page ajoutée.
|
||||
func TestProjSnapPageAjouteeEnMiseAJour(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
b1 := playTurn(t, m, c, caps, "bonjour")
|
||||
if !strings.Contains(systemContent(t, b1), projSnapSystemLine) {
|
||||
t.Fatal("ligne fixe du système absente avec la clé")
|
||||
}
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b2 := playTurn(t, m, c, caps, "et le NAS ?")
|
||||
u1, u2 := userContents(t, b1), userContents(t, b2)
|
||||
if u2[0] != u1[0] {
|
||||
t.Fatalf("premier message changé :\navant %q\naprès %q", u1[0], u2[0])
|
||||
}
|
||||
last := u2[len(u2)-1]
|
||||
if !strings.HasPrefix(last, ctxUpdOpen) || !strings.Contains(last, "](nas.md)") || !strings.HasSuffix(last, "et le NAS ?") {
|
||||
t.Fatalf("mise à jour attendue en tête du nouveau message : %q", last)
|
||||
}
|
||||
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b3 := playTurn(t, m, c, caps, "et docker ?")
|
||||
u3 := userContents(t, b3)
|
||||
if u3[0] != u1[0] || u3[1] != u2[1] {
|
||||
t.Fatalf("messages déjà envoyés modifiés :\n%q\n%q", u3[0], u3[1])
|
||||
}
|
||||
last = u3[len(u3)-1]
|
||||
if !strings.Contains(last, "+ [Docker](docker.md)") || strings.Contains(last, "nas.md") {
|
||||
t.Fatalf("seule la page ajoutée doit être annoncée : %q", last)
|
||||
}
|
||||
// Sans changement : rien de posé.
|
||||
b4 := playTurn(t, m, c, caps, "merci")
|
||||
if u4 := userContents(t, b4); u4[len(u4)-1] != "merci" {
|
||||
t.Fatalf("mise à jour vide posée : %q", u4[len(u4)-1])
|
||||
}
|
||||
// Persisté et relu : l'état annoncé survit.
|
||||
raw, _ := json.Marshal(c)
|
||||
c2 := newTestConv()
|
||||
c2.loadFrom("x", raw)
|
||||
if c2.ProjSnap == nil || c2.ProjSnap.State.Mem["docker.md"] == "" {
|
||||
t.Fatalf("instantané non relu : %+v", c2.ProjSnap)
|
||||
}
|
||||
}
|
||||
|
||||
// Une nouvelle valeur de tracker remplace sa ligne (« ~ ») ; un tracker supprimé
|
||||
// est annoncé comme tel.
|
||||
func TestProjSnapTrackerLigneRemplacee(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
if _, err := trackerAdd("poids", "2026-10-01", "72.4 kg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
b1 := playTurn(t, m, c, caps, "salut")
|
||||
if u := userContents(t, b1); !strings.Contains(u[0], "Trackers (tracker tool) as of ") || strings.Contains(u[0], "answer straight from this") {
|
||||
t.Fatalf("en-tête figé attendu : %q", u[0])
|
||||
}
|
||||
if _, err := trackerAdd("poids", "2026-10-03", "73 kg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b2 := playTurn(t, m, c, caps, "mon poids ?")
|
||||
u := userContents(t, b2)
|
||||
if u[0] != userContents(t, b1)[0] {
|
||||
t.Fatal("premier message changé")
|
||||
}
|
||||
if !strings.Contains(u[len(u)-1], "~ poids — 73 kg (2026-10-03)") {
|
||||
t.Fatalf("ligne de tracker attendue : %q", u[len(u)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// Une compaction (début, fin, manuelle) reprend le bloc tout neuf au tour suivant
|
||||
// et retire les mises à jour de la queue gardée : une vieille valeur ne doit pas
|
||||
// suivre la fraîche.
|
||||
func TestProjSnapCompactionRafraichit(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
if _, err := trackerAdd("poids", "2026-10-01", "72.4 kg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
playTurn(t, m, c, caps, "salut")
|
||||
if _, err := trackerAdd("poids", "2026-10-02", "72.8 kg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
playTurn(t, m, c, caps, "note-le")
|
||||
if !hasContextUpdates(c.Messages) {
|
||||
t.Fatal("mise à jour attendue dans l'historique")
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
c.Messages = append(c.Messages, am("étape "+strings.Repeat("détail ", 300)), um("continue "+strings.Repeat("x", 600)))
|
||||
}
|
||||
// Le message avec la mise à jour en dernier : il tombe dans la queue gardée.
|
||||
upd := um(ctxUpdOpen + ` at="x">` + "\n~ poids — 72.4 kg\n" + ctxUpdClose + "\n\nencore")
|
||||
upd.CtxUpd = true
|
||||
c.Messages = append(c.Messages, am("vu"), upd)
|
||||
if _, changed := c.compactAndPublish(context.Background(), c.epoch, "test", append([]Message(nil), c.Messages...), 50000, caps, compactOpts{}); !changed {
|
||||
t.Fatal("compaction sans effet")
|
||||
}
|
||||
if c.ProjSnap != nil || hasContextUpdates(c.Messages) {
|
||||
t.Fatalf("après compaction : instantané %v, mises à jour restantes %v", c.ProjSnap != nil, hasContextUpdates(c.Messages))
|
||||
}
|
||||
b := playTurn(t, m, c, caps, "et maintenant ?")
|
||||
if strings.Contains(strings.Join(userContents(t, b), " "), ctxUpdOpen) {
|
||||
t.Fatalf("mise à jour après le bloc neuf : %s", b)
|
||||
}
|
||||
if u := userContents(t, b); !strings.Contains(u[0], "72.8 kg") {
|
||||
t.Fatalf("bloc neuf attendu avec la dernière valeur : %q", u[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Compaction EN COURS de tour : le bloc vivant remplace le figé dans la requête
|
||||
// rejouée (la mise à jour avalée par le résumé n'est pas perdue), et c'est lui
|
||||
// que le tour suivant renvoie.
|
||||
func TestProjSnapCompactionEnTour(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
playTurn(t, m, c, caps, "salut")
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 12; i++ {
|
||||
c.Messages = append(c.Messages, am("étape "+strings.Repeat("détail ", 300)), um("continue "+strings.Repeat("x", 600)))
|
||||
}
|
||||
c.Messages = append(c.Messages, am("vu"))
|
||||
n := len(m.all())
|
||||
m.mu.Lock()
|
||||
m.reply = func(k int, _ string) (int, string) {
|
||||
if k == n {
|
||||
return 400, `{"error":{"code":400,"message":"request (99999 tokens) exceeds the available context size"}}`
|
||||
}
|
||||
return 200, sseChunk("ok.") + sseFinal("stop", 100, 2)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
playTurn(t, m, c, caps, "et le NAS ?")
|
||||
all := m.all()
|
||||
first, retry := all[n], all[n+1]
|
||||
if uf := userContents(t, first); !strings.HasPrefix(uf[len(uf)-1], ctxUpdOpen) {
|
||||
t.Fatal("mise à jour attendue dans la requête refusée")
|
||||
}
|
||||
u := userContents(t, retry)
|
||||
if strings.Contains(strings.Join(u, " "), ctxUpdOpen) {
|
||||
t.Fatalf("mise à jour restée après la compaction en cours de tour : %q", u)
|
||||
}
|
||||
if !strings.Contains(u[0], "](nas.md)") {
|
||||
t.Fatalf("la page doit être dans le bloc rafraîchi : %q", u[0])
|
||||
}
|
||||
if c.ProjSnap == nil || c.ProjSnap.State.Mem["nas.md"] == "" || hasContextUpdates(c.Messages) {
|
||||
t.Fatalf("instantané rafraîchi non rangé : %+v", c.ProjSnap)
|
||||
}
|
||||
next := playTurn(t, m, c, caps, "merci")
|
||||
head := func(s string) string { return s[:strings.Index(s, "</project_context>")] }
|
||||
if head(userContents(t, next)[0]) != head(u[0]) {
|
||||
t.Fatal("le tour suivant doit renvoyer le bloc rafraîchi à l'identique")
|
||||
}
|
||||
}
|
||||
|
||||
// Changer de projet, de discussion ou repartir à zéro ne garde pas l'instantané.
|
||||
func TestProjSnapProjetEtDiscussion(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
playTurn(t, m, c, caps, "salut")
|
||||
p, err := createProject("Jardin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setProjectDesc(p.Slug, "Le potager."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setActiveProject(p.Slug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := playTurn(t, m, c, caps, "et le jardin ?")
|
||||
u := userContents(t, b)
|
||||
if !strings.Contains(u[0], "Le potager.") || strings.Contains(strings.Join(u, " "), ctxUpdOpen) {
|
||||
t.Fatalf("changement de projet : bloc neuf attendu, sans mise à jour : %q", u[0])
|
||||
}
|
||||
if c.ProjSnap == nil {
|
||||
t.Fatal("instantané attendu")
|
||||
}
|
||||
// loadFrom : fil neuf, ou enregistré sans le champ.
|
||||
c.loadFrom("neuf", nil)
|
||||
if c.ProjSnap != nil {
|
||||
t.Fatal("fil neuf : instantané hérité")
|
||||
}
|
||||
c.ProjSnap = &projSnapshot{Key: "x"}
|
||||
c.loadFrom("ancien", []byte(`{"messages":[{"role":"user","content":"vieux"}]}`))
|
||||
if c.ProjSnap != nil {
|
||||
t.Fatal("fil enregistré sans instantané : hérité")
|
||||
}
|
||||
c.ProjSnap = &projSnapshot{Key: "x"}
|
||||
c.Reset()
|
||||
if c.ProjSnap != nil {
|
||||
t.Fatal("Reset garde l'instantané")
|
||||
}
|
||||
}
|
||||
|
||||
// Sans agent : ni bloc ni mise à jour, et les blocs restés d'avant ne partent pas.
|
||||
func TestProjSnapSansAgent(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
c := newTestConv()
|
||||
playTurn(t, m, c, Caps{Agent: true, Mem: MemAlways}, "salut")
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
playTurn(t, m, c, Caps{Agent: true, Mem: MemAlways}, "le NAS ?")
|
||||
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := c.ProjSnap.State
|
||||
b := playTurn(t, m, c, Caps{}, "réponds sans outils")
|
||||
if strings.Contains(b, "context_update") || strings.Contains(b, "project_context") {
|
||||
t.Fatalf("agent off : contexte projet envoyé : %s", b)
|
||||
}
|
||||
if last := c.Messages[len(c.Messages)-2]; msgText(last) != "réponds sans outils" {
|
||||
t.Fatalf("agent off : message modifié : %q", msgText(last))
|
||||
}
|
||||
if !reflect.DeepEqual(c.ProjSnap.State, before) {
|
||||
t.Fatal("agent off : état annoncé modifié")
|
||||
}
|
||||
}
|
||||
|
||||
// Clé retirée après usage : l'instantané et les blocs sont nettoyés, retour au
|
||||
// bloc vivant sans vieille mise à jour derrière lui.
|
||||
func TestProjSnapCleRetiree(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
playTurn(t, m, c, caps, "salut")
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
playTurn(t, m, c, caps, "le NAS ?")
|
||||
if err := SetConfigKey("PROJ_SNAPSHOT", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := playTurn(t, m, c, caps, "et après ?")
|
||||
if strings.Contains(b, "context_update") || strings.Contains(b, "as of") {
|
||||
t.Fatalf("restes du bloc figé : %s", b)
|
||||
}
|
||||
if c.ProjSnap != nil || hasContextUpdates(c.Messages) {
|
||||
t.Fatal("historique non nettoyé")
|
||||
}
|
||||
}
|
||||
|
||||
// Rafraîchissement : clé de validité (redémarrage, réglage moteur), système ou
|
||||
// outils changés, seuil d'accumulation.
|
||||
func TestProjSnapApplyRafraichit(t *testing.T) {
|
||||
projSnapSetup(t, true)
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
live := projSnapCapture(caps, "t0")
|
||||
c := newTestConv()
|
||||
c.Messages = []Message{um("a")}
|
||||
if r, _ := c.projSnapApplyLocked(live, "k", "s", "t0"); !r {
|
||||
t.Fatal("premier tour : instantané attendu")
|
||||
}
|
||||
if err := MemAdd("docker.md", "# Docker\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
live2 := projSnapCapture(caps, "t1")
|
||||
c.Messages = append(c.Messages, am("b"), um("c"))
|
||||
if r, d := c.projSnapApplyLocked(live2, "k", "s", "t1"); r || d == "" {
|
||||
t.Fatalf("mise à jour attendue : refresh=%v delta=%q", r, d)
|
||||
}
|
||||
for _, tc := range []struct{ name, key, sys string }{{"clé", "k2", "s"}, {"système", "k", "s2"}} {
|
||||
snap := *c.ProjSnap
|
||||
c.Messages = append(c.Messages, am("d"), um("e"))
|
||||
if r, _ := c.projSnapApplyLocked(live, tc.key, tc.sys, "t2"); !r {
|
||||
t.Fatalf("%s changé : rafraîchissement attendu", tc.name)
|
||||
}
|
||||
if hasContextUpdates(c.Messages) {
|
||||
t.Fatalf("%s : mises à jour restées après rafraîchissement", tc.name)
|
||||
}
|
||||
c.ProjSnap = &snap
|
||||
}
|
||||
// Seuil : les mises à jour accumulées dépassent la limite.
|
||||
c.ProjSnap.Key, c.ProjSnap.SysHash = "k", "s"
|
||||
c.ProjSnap.DeltaTok = projSnapDeltaLimit(c.ProjSnap.SnapTok)
|
||||
c.ProjSnap.State = live.state
|
||||
if r, _ := c.projSnapApplyLocked(live2, "k", "s", "t3"); !r {
|
||||
t.Fatal("seuil dépassé : rafraîchissement attendu")
|
||||
}
|
||||
// La clé suit le lancement de Loki.
|
||||
k1 := projSnapKey(caps, ReadConfig())
|
||||
old := projSnapBoot
|
||||
projSnapBoot = "autre"
|
||||
defer func() { projSnapBoot = old }()
|
||||
if projSnapKey(caps, ReadConfig()) == k1 {
|
||||
t.Fatal("redémarrage : clé inchangée")
|
||||
}
|
||||
projSnapBoot = old
|
||||
if err := SetConfigKey("REASONING_EFFORT", "high"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if projSnapKey(caps, ReadConfig()) != k1 {
|
||||
t.Fatal("un réglage de conversation ne doit pas rafraîchir")
|
||||
}
|
||||
if err := SetConfigKey("MODEL", "/models/autre.gguf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if projSnapKey(caps, ReadConfig()) == k1 {
|
||||
t.Fatal("changement de modèle : clé inchangée")
|
||||
}
|
||||
}
|
||||
|
||||
// La prose repart en entier ; les sections faites de lignes, ligne à ligne.
|
||||
func TestProjDeltaParType(t *testing.T) {
|
||||
projSnapSetup(t, true)
|
||||
old := projState{
|
||||
Code: "Repository instructions (AGENTS.md) — follow them:\n\nmake test", Desc: "Ancienne description.",
|
||||
MemOn: true, MemProse: "# Index", Mem: map[string]string{"a.md": "- [A](a.md)", "b.md": "- [B](b.md)"},
|
||||
Trk: map[string]string{"poids": "poids — 72 kg (2026-10-01)", "pas": "pas — 8000 (2026-10-01)"},
|
||||
}
|
||||
lv := projLive{
|
||||
state: projState{
|
||||
Code: "Repository instructions (AGENTS.md) — follow them:\n\ngo test ./...", Desc: "Nouvelle description,\nsur deux lignes.",
|
||||
MemOn: true, MemProse: "# Index", Mem: map[string]string{"a.md": "- [A](a.md) — accroche", "c.md": "- [C](c.md)"},
|
||||
Trk: map[string]string{"poids": "poids — 73 kg (2026-10-03)", "eau": "eau — 2 L (2026-10-03)"},
|
||||
},
|
||||
memOrder: []string{"a.md", "c.md"},
|
||||
trkOrder: []string{"poids", "eau"},
|
||||
}
|
||||
d := projDelta(old, lv, "2026-10-04 09:00")
|
||||
for _, want := range []string{
|
||||
ctxUpdOpen + ` at="2026-10-04 09:00">`,
|
||||
"go test ./...",
|
||||
"Nouvelle description,\nsur deux lignes.",
|
||||
"~ [A](a.md) — accroche", "+ [C](c.md)", "- b.md (deleted)",
|
||||
"~ poids — 73 kg (2026-10-03)", "+ eau — 2 L (2026-10-03)", "- pas — 8000 (2026-10-01) (deleted)",
|
||||
} {
|
||||
if !strings.Contains(d, want) {
|
||||
t.Fatalf("manque %q dans :\n%s", want, d)
|
||||
}
|
||||
}
|
||||
if strings.Contains(d, "make test") || strings.Contains(d, "Ancienne") {
|
||||
t.Fatalf("l'ancien texte ne doit pas repartir :\n%s", d)
|
||||
}
|
||||
// En-tête de l'index modifié : l'index repart en entier.
|
||||
lv.state.MemProse = "# Index (revu)"
|
||||
lv.memMsg = Message{Content: "Project memory index … # Index (revu)"}
|
||||
if d := projDelta(old, lv, "t"); !strings.Contains(d, "Current version, replaces any earlier one — Project memory index") {
|
||||
t.Fatalf("prose de l'index modifiée : index complet attendu :\n%s", d)
|
||||
}
|
||||
if projDelta(old, projLive{state: old}, "t") != "" {
|
||||
t.Fatal("rien de changé : mise à jour vide attendue")
|
||||
}
|
||||
}
|
||||
|
||||
// Les blocs sont retirés partout où le texte de l'utilisateur est lu : tâche du
|
||||
// vérificateur, titre, export, entrée du résumeur ; en texte et en multimodal.
|
||||
func TestProjSnapRetireDesLecteurs(t *testing.T) {
|
||||
testHome(t)
|
||||
blk := ctxUpdOpen + ` at="t">` + "\n+ [A](a.md)\n" + ctxUpdClose + "\n\n"
|
||||
c := newTestConv()
|
||||
first := um(blk + "corrige le build")
|
||||
first.CtxUpd = true
|
||||
c.Messages = []Message{first, am("ok")}
|
||||
if got := c.lastUserText(); got != "corrige le build" {
|
||||
t.Fatalf("tâche du vérificateur : %q", got)
|
||||
}
|
||||
if got := convSummary(c.Messages); got != "corrige le build" {
|
||||
t.Fatalf("titre : %q", got)
|
||||
}
|
||||
out, err := c.ExportJSON(exportOpts{})
|
||||
if err != nil || strings.Contains(string(out), "context_update") {
|
||||
t.Fatalf("export : %v %s", err, out)
|
||||
}
|
||||
multi := []Message{
|
||||
{Role: "user", CtxUpd: true, Content: []any{map[string]any{"type": "text", "text": blk}, map[string]any{"type": "text", "text": "vois l'image"}}},
|
||||
{Role: "user", CtxUpd: true, Content: []map[string]any{{"type": "text", "text": blk + "suite"}}},
|
||||
}
|
||||
got := stripContextUpdates(multi)
|
||||
if hasContextUpdates(got) || len(got[0].Content.([]any)) != 1 || got[1].Content.([]map[string]any)[0]["text"] != "suite" {
|
||||
t.Fatalf("multimodal : %+v", got)
|
||||
}
|
||||
if hasContextUpdates(multi) == false {
|
||||
t.Fatal("l'original ne doit pas être modifié")
|
||||
}
|
||||
// Un texte qui en parle plus loin n'est pas touché.
|
||||
plain := []Message{{Role: "user", CtxUpd: true, Content: "explique " + blk}}
|
||||
if s := stripContextUpdates(plain); msgText(s[0]) != msgText(plain[0]) {
|
||||
t.Fatal("bloc hors tête retiré")
|
||||
}
|
||||
// Mise à jour posée puis retirée : retour exact au texte.
|
||||
for _, m0 := range []Message{um("x"), {Role: "user", Content: []any{map[string]any{"type": "text", "text": "vois"}}}} {
|
||||
m1, ok := prependCtxUpdate(m0, blk)
|
||||
if !ok {
|
||||
t.Fatal("pose refusée")
|
||||
}
|
||||
m2, _ := withoutCtxUpdate(m1)
|
||||
if !reflect.DeepEqual(jsonValue(t, m2), jsonValue(t, m0)) {
|
||||
t.Fatalf("aller-retour : %+v", m2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Valeurs de la clé ; un preset externe n'est jamais concerné.
|
||||
func TestProjSnapEnabled(t *testing.T) {
|
||||
for v, want := range map[string]bool{"": false, "off": false, "0": false, "on": true, "1": true, "oui": true, " ON ": true} {
|
||||
if got := projSnapEnabled(map[string]string{"PROJ_SNAPSHOT": v}); got != want {
|
||||
t.Fatalf("%q : %v", v, got)
|
||||
}
|
||||
}
|
||||
if projSnapEnabled(map[string]string{"PROJ_SNAPSHOT": "on", extKeyFlag: "1"}) {
|
||||
t.Fatal("preset externe : jamais de bloc figé")
|
||||
}
|
||||
}
|
||||
|
||||
// replaceProjectHead garde le rappel des pages lues, après le bloc neuf.
|
||||
func TestReplaceProjectHead(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: "base"},
|
||||
{Role: "system", Content: projectContextPrefix + " — vieux"},
|
||||
{Role: "system", Content: memIndexPrefix + " vieux"},
|
||||
{Role: "system", Content: memReminderPrefix + " a.md"},
|
||||
um("q"),
|
||||
}
|
||||
block := []Message{{Role: "system", Content: memIndexPrefix + " neuf"}}
|
||||
out := replaceProjectHead(msgs, block)
|
||||
var got []string
|
||||
for _, m := range out {
|
||||
got = append(got, msgText(m))
|
||||
}
|
||||
want := []string{"base", memIndexPrefix + " neuf", memReminderPrefix + " a.md", "q"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
// Bloc vide au départ : inséré après le système commun.
|
||||
out = replaceProjectHead([]Message{{Role: "system", Content: "base"}, um("q")}, block)
|
||||
if msgText(out[1]) != memIndexPrefix+" neuf" || msgText(out[2]) != "q" {
|
||||
t.Fatalf("%+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Corrections de relecture du lot 2 : seul un bloc posé par Loki (marqué) est
|
||||
// retiré. Sans la clé, un message TAPÉ qui commence par la même balise reste
|
||||
// tel quel partout — historique, envoi, titre — et la marque ne part jamais
|
||||
// au moteur.
|
||||
func TestProjSnapTexteTapeIntact(t *testing.T) {
|
||||
testHome(t)
|
||||
typed := um(ctxUpdOpen + ` at="x">` + "\ncollé depuis un journal\n" + ctxUpdClose + "\n\nqu'est-ce que c'est ?")
|
||||
c := newTestConv()
|
||||
c.Messages = []Message{typed}
|
||||
if got := stripContextUpdates(c.Messages); msgText(got[0]) != msgText(typed) {
|
||||
t.Fatal("texte tapé retouché")
|
||||
}
|
||||
sent, _, _ := c.turnView(Caps{Agent: true}, c.epoch, append([]Message(nil), c.Messages...), true)
|
||||
if msgText(sent[len(sent)-1]) != msgText(typed) || msgText(c.Messages[0]) != msgText(typed) {
|
||||
t.Fatalf("texte tapé retouché à l'envoi ou dans l'historique : %q", msgText(sent[len(sent)-1]))
|
||||
}
|
||||
if got := convSummary(c.Messages); !strings.Contains(got, "collé depuis un journal") {
|
||||
t.Fatalf("titre : %q", got)
|
||||
}
|
||||
marked, ok := prependCtxUpdate(um("suite"), ctxUpdOpen+` at="t">`+"\n+ x\n"+ctxUpdClose+"\n\n")
|
||||
if !ok || !marked.CtxUpd {
|
||||
t.Fatal("bloc posé sans marque")
|
||||
}
|
||||
wire, _ := wireMessages([]Message{marked}, false, echoPolicy{})
|
||||
b, _ := json.Marshal(wire)
|
||||
if strings.Contains(string(b), "ctx_upd") {
|
||||
t.Fatalf("marque envoyée au moteur : %s", b)
|
||||
}
|
||||
if s, ok := withoutCtxUpdate(marked); !ok || s.CtxUpd || msgText(s) != "suite" {
|
||||
t.Fatalf("bloc marqué non retiré : %+v", s)
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,45 @@ import (
|
||||
//
|
||||
// Approximation 1 tok ≈ 4 caractères : suffisante pour une alerte, et sans
|
||||
// dépendance à un tokenizer.
|
||||
const promptCharBudget = 7500 // ~1875 tokens, tout allumé
|
||||
//
|
||||
// Le budget n'a été relevé que pour de VRAIS outils nouveaux, jamais pour des
|
||||
// lignes de prompt — et chaque palier dit lequel :
|
||||
//
|
||||
// 7500 → 7900 : recall + recall_search (chat_recall.go), ~720 car. Le compactage
|
||||
// cesse d'être destructif : le modèle ne relance plus les lectures
|
||||
// et recherches déjà faites. Il récupère bien plus qu'il ne coûte.
|
||||
// 7900 → 8600 : tracker (tracker.go), ~620 car. Un seul schéma pour les quatre
|
||||
// actions — quatre outils auraient coûté quatre fois ça.
|
||||
// 8600 → 11000 : les quatre outils task_* (tasks_tools.go), ~1290 car. L'IA se
|
||||
// donne elle-même ses rappels et ses veilles ; sans eux il fallait
|
||||
// passer par l'interface. Descriptions déjà au plus court, et un
|
||||
// seul schéma par action (create/update/delete ne partagent pas
|
||||
// assez d'arguments pour fusionner sans rendre chacun ambigu).
|
||||
// Les ~500 car restants couvrent web_screenshot, déclaré dès que
|
||||
// Playwright est présent — c'est le cas dans l'image, pas sur le
|
||||
// runner de CI : le budget doit tenir pour l'image, la vraie.
|
||||
// (Le pilotage de navigateur, lui, est HORS de ce décompte : ses
|
||||
// schémas n'existent que si on arme son interrupteur.)
|
||||
//
|
||||
// Les ~200 car de prompt qui les accompagnent sont le seul écart
|
||||
// assumé à la règle « pas de relèvement pour du prompt » : un outil
|
||||
// qu'aucune ligne ne mentionne n'est jamais appelé de lui-même, et
|
||||
// la ligne dit ce que les schémas ne disent pas (quand se planifier,
|
||||
// et qu'un script planifié ne charge aucun modèle). Elle a été
|
||||
// ramenée de trois lignes à une.
|
||||
//
|
||||
// Descriptions déjà réduites au strict nécessaire. La consigne d'origine tient
|
||||
// pour la suite : ce budget ne se relève pas pour du prompt.
|
||||
const promptCharBudget = 11000 // ~2750 tokens, tout allumé
|
||||
|
||||
func TestSystemPromptStaysLean(t *testing.T) {
|
||||
// $LOKI_HOME isolé : les outils MCP configurés sur la machine de test (et
|
||||
// leur ligne au préambule) ne comptent pas dans le budget de Loki.
|
||||
testHome(t)
|
||||
caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
|
||||
sp := baseSystemPrompt(caps)
|
||||
tb, err := json.Marshal(EnabledTools(caps))
|
||||
tools := EnabledTools(caps)
|
||||
sp := baseSystemPrompt(caps, tools)
|
||||
tb, err := json.Marshal(tools)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -40,7 +73,7 @@ func TestSystemPromptStaysLean(t *testing.T) {
|
||||
// Le prompt ne doit pas redevenir un catalogue d'outils : leurs schémas partent
|
||||
// dans la MÊME requête et les décrivent déjà un par un.
|
||||
func TestSystemPromptDoesNotRelistTools(t *testing.T) {
|
||||
sp := baseSystemPrompt(Caps{Agent: true, Internet: true, Mem: MemAlways})
|
||||
sp := baseSystemPrompt(Caps{Agent: true, Internet: true, Mem: MemAlways}, nil)
|
||||
for _, line := range strings.Split(sp, "\n") {
|
||||
l := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(l, "- ") {
|
||||
@@ -59,7 +92,7 @@ func TestSystemPromptDoesNotRelistTools(t *testing.T) {
|
||||
// ordonne d'appeler des outils invente des appels textuels qui fuient dans la
|
||||
// réponse.
|
||||
func TestSystemPromptEmptyWithoutTools(t *testing.T) {
|
||||
if sp := baseSystemPrompt(Caps{Mem: MemOff}); sp != "" {
|
||||
if sp := baseSystemPrompt(Caps{Mem: MemOff}, nil); sp != "" {
|
||||
t.Fatalf("préambule non vide sans outils : %q", sp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Pendant une génération de chat, un envoi est mis en file ; il ressort à la
|
||||
// frontière d'étape suivante, journalisé comme une bulle utilisateur.
|
||||
func TestQueueDuringGeneration(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.Generating = true
|
||||
queued, err := c.EnqueueOrStart("cid-1", "précision", nil, Caps{}, 0.7)
|
||||
if err != nil || !queued {
|
||||
t.Fatalf("mise en file attendue, got queued=%v err=%v", queued, err)
|
||||
}
|
||||
// Réessai du même envoi (réponse perdue) : pas de doublon.
|
||||
if _, err := c.EnqueueOrStart("cid-1", "précision", nil, Caps{}, 0.7); err != ErrDupSend {
|
||||
t.Fatalf("doublon attendu, got %v", err)
|
||||
}
|
||||
msgs := c.drainQueued(c.epoch)
|
||||
if len(msgs) != 1 || msgs[0].Role != "user" {
|
||||
t.Fatalf("un message user attendu, got %#v", msgs)
|
||||
}
|
||||
if len(c.Log) != 1 || c.Log[0].Delta["user"] != "précision" {
|
||||
t.Fatalf("le message injecté doit apparaître dans le fil, journal : %#v", c.Log)
|
||||
}
|
||||
if again := c.drainQueued(c.epoch); len(again) != 0 {
|
||||
t.Fatal("la file doit être vidée par drainQueued")
|
||||
}
|
||||
}
|
||||
|
||||
// Une tâche planifiée qui occupe le modèle garde le refus : sa fin ne dépile rien.
|
||||
func TestQueueRefusedDuringTask(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.Generating = true
|
||||
c.runningTaskName = "veille"
|
||||
if queued, _ := c.EnqueueOrStart("", "x", nil, Caps{}, 0.7); queued {
|
||||
t.Fatal("pas de file pendant une tâche planifiée")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop : la file est abandonnée et le client en est prévenu.
|
||||
func TestDropQueuedAnnounces(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.Generating = true
|
||||
_, _ = c.EnqueueOrStart("", "a", nil, Caps{}, 0.7)
|
||||
c.dropQueued(c.epoch)
|
||||
if len(c.queued) != 0 || len(c.Log) != 1 || c.Log[0].Delta["queue_dropped"] != 1 {
|
||||
t.Fatalf("abandon non signalé : file=%d journal=%#v", len(c.queued), c.Log)
|
||||
}
|
||||
}
|
||||
|
||||
// Un client dont la discussion affichée n'est plus l'active reçoit d'abord un
|
||||
// reset, pour ne pas greffer le nouveau fil sur l'ancien.
|
||||
func TestSubscribeStaleConvResets(t *testing.T) {
|
||||
testHome(t)
|
||||
_ = putStr(bkChat, ckActive, "c-nouvelle")
|
||||
c := newTestConv()
|
||||
for i := 0; i < 3; i++ {
|
||||
c.appendDelta(c.epoch, map[string]any{"user": "q"})
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var first map[string]any
|
||||
c.SubscribeTail(ctx, 2, -1, "c-ancienne", func(m map[string]any) bool {
|
||||
if _, pad := m["pad"]; pad {
|
||||
return true
|
||||
}
|
||||
if first == nil {
|
||||
first = m
|
||||
}
|
||||
if _, ok := m["caught_up"]; ok {
|
||||
cancel()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if first["reset"] != true || first["id"] != "c-nouvelle" {
|
||||
t.Fatalf("reset attendu en premier, got %v", first)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// chat_recall.go — mémoire longue de la conversation.
|
||||
//
|
||||
// Le compactage résume les vieux tours pour libérer la fenêtre, mais un résumé
|
||||
// perd forcément du détail (un gros code, une longue page web, un diff). Plutôt
|
||||
// que de le perdre pour de bon, on ARCHIVE chaque gros bloc AVANT de le
|
||||
// compacter, sous un identifiant court et stable (r7, r42…). Le résumé cite l'id
|
||||
// à la place du bloc (« code du jeu fourni → recall:r7 »), et le modèle peut le
|
||||
// rappeler VERBATIM quand il en a besoin :
|
||||
//
|
||||
// recall(id) → ramène le bloc exact, tel qu'il était.
|
||||
// recall_search(query) → retrouve un bloc (et son id) par mots-clés, même très
|
||||
// ancien et disparu du résumé courant.
|
||||
//
|
||||
// L'archive est GLOBALE (un compteur monotone via bbolt NextSequence), pas liée
|
||||
// à une discussion : un id ne pointe jamais que vers un seul bloc, pour toujours.
|
||||
// Elle vit sur disque (bucket bkRecall) donc survit aux redémarrages et au
|
||||
// multi-appareils. Le contexte, lui, ne porte que les ids ENCORE cités par le
|
||||
// résumé courant : sa taille reste plate même sur une conversation infinie,
|
||||
// pendant que l'archive grossit tranquillement sur disque (bon marché).
|
||||
//
|
||||
// En mode code, c'est ce qui rend une longue session tenable : un fichier lu ou
|
||||
// un diff produit tôt dans la session n'est plus perdu au compactage suivant.
|
||||
|
||||
const (
|
||||
// Un bloc du torse plus long que ça (en caractères) est archivé et remplacé
|
||||
// par son id. En dessous, le résumé le porte très bien tout seul : pas la peine
|
||||
// de créer un id pour trois lignes.
|
||||
recallArchiveMinLen = 800
|
||||
// Longueur de l'extrait de tête laissé au résumeur pour un bloc archivé : de
|
||||
// quoi le résumer fidèlement, le reste étant récupérable par recall.
|
||||
recallSummaryHeadLen = 400
|
||||
// Nombre de résultats par défaut / max pour recall_search.
|
||||
recallSearchDefault = 5
|
||||
recallSearchMax = 20
|
||||
)
|
||||
|
||||
// recallBlock est un bloc archivé, adressable par son id.
|
||||
type recallBlock struct {
|
||||
ID string `json:"id"` // "r<seq>"
|
||||
Seq uint64 `json:"seq"` // séquence brute (tri, clé)
|
||||
Label string `json:"label"` // libellé court lisible (rôle + première ligne)
|
||||
Role string `json:"role"` // rôle d'origine (tool/user/assistant)
|
||||
Content string `json:"content"` // contenu VERBATIM
|
||||
Created int64 `json:"created"` // unix, pour un éventuel ménage futur
|
||||
}
|
||||
|
||||
// recallKey encode une séquence en clé bbolt triable (big-endian).
|
||||
func recallKey(seq uint64) []byte {
|
||||
k := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(k, seq)
|
||||
return k
|
||||
}
|
||||
|
||||
// recallIDToSeq lit "r<n>" → n. Tolère un id sans préfixe et les espaces : le
|
||||
// modèle écrit aussi bien recall("r7") que recall("7") ou recall("R7 ").
|
||||
func recallIDToSeq(id string) (uint64, bool) {
|
||||
s := strings.TrimSpace(id)
|
||||
s = strings.TrimPrefix(s, "r")
|
||||
s = strings.TrimPrefix(s, "R")
|
||||
n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// archiveRecallBlock enregistre un bloc et renvoie son id ("r<seq>"). L'id est
|
||||
// alloué par NextSequence dans la transaction d'écriture : monotone, sans
|
||||
// collision, même si deux compactages se croisent. En cas d'échec d'écriture
|
||||
// (base indisponible, ex. tests sans LOKI_HOME) on renvoie une erreur et
|
||||
// l'appelant se rabat sur le compactage classique (troncature sans id).
|
||||
func archiveRecallBlock(label, role, content string) (string, error) {
|
||||
// Résolu AVANT d'ouvrir la base : chiffrer dans la transaction relirait la
|
||||
// configuration, donc la base, qui n'est pas rentrante (voir memEncoderNow).
|
||||
encode, err := memEncoderNow()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var id string
|
||||
err = withDB(func(d *bolt.DB) error {
|
||||
return d.Update(func(tx *bolt.Tx) error {
|
||||
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seq, err := b.NextSequence()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blk := recallBlock{
|
||||
ID: "r" + strconv.FormatUint(seq, 10),
|
||||
Seq: seq,
|
||||
Label: label,
|
||||
Role: role,
|
||||
Content: content,
|
||||
Created: time.Now().Unix(),
|
||||
}
|
||||
id = blk.ID
|
||||
raw, err := json.Marshal(blk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Un bloc archivé, c'est du verbatim de conversation : chiffré comme
|
||||
// le reste quand la mémoire l'est (l'encodeur rend le clair inchangé
|
||||
// si le chiffrement est inactif ; verrouillé, memEncoderNow a déjà
|
||||
// refusé plus haut).
|
||||
enc, err := encode(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put(recallKey(seq), enc)
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cacheBust(bkRecall)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// recallGet ramène un bloc par son id. false si l'id est mal formé ou absent.
|
||||
func recallGet(id string) (recallBlock, bool) {
|
||||
seq, ok := recallIDToSeq(id)
|
||||
if !ok {
|
||||
return recallBlock{}, false
|
||||
}
|
||||
var blk recallBlock
|
||||
found := false
|
||||
_ = view(bkRecall, func(b *bolt.Bucket) error {
|
||||
v := b.Get(recallKey(seq))
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
dec, err := decodeMemContent(v)
|
||||
if err != nil {
|
||||
return nil // chiffré et verrouillé : le bloc reste hors de portée
|
||||
}
|
||||
found = json.Unmarshal(dec, &blk) == nil
|
||||
return nil
|
||||
})
|
||||
return blk, found
|
||||
}
|
||||
|
||||
// recallSearch cherche dans toute l'archive par recouvrement de mots-clés.
|
||||
// Score = nombre de termes distincts de la requête présents dans (libellé +
|
||||
// contenu), le libellé comptant double (un mot du titre est plus signifiant).
|
||||
// Simple, lexical, zéro dépendance — l'issue de secours pour un bloc sorti du
|
||||
// résumé courant. À égalité, le plus RÉCENT d'abord (seq décroissante).
|
||||
func recallSearch(query string, limit int) []recallBlock {
|
||||
terms := recallTokens(query)
|
||||
if len(terms) == 0 {
|
||||
return nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = recallSearchDefault
|
||||
}
|
||||
if limit > recallSearchMax {
|
||||
limit = recallSearchMax
|
||||
}
|
||||
type scored struct {
|
||||
blk recallBlock
|
||||
score int
|
||||
}
|
||||
var hits []scored
|
||||
_ = view(bkRecall, func(b *bolt.Bucket) error {
|
||||
return b.ForEach(func(_, v []byte) error {
|
||||
var blk recallBlock
|
||||
dec, err := decodeMemContent(v)
|
||||
if err != nil {
|
||||
return nil // chiffré et verrouillé : ce bloc ne participe pas
|
||||
}
|
||||
if json.Unmarshal(dec, &blk) != nil {
|
||||
return nil
|
||||
}
|
||||
label := strings.ToLower(blk.Label)
|
||||
body := strings.ToLower(blk.Content)
|
||||
score := 0
|
||||
for t := range terms {
|
||||
if strings.Contains(label, t) {
|
||||
score += 2
|
||||
}
|
||||
if strings.Contains(body, t) {
|
||||
score++
|
||||
}
|
||||
}
|
||||
if score > 0 {
|
||||
hits = append(hits, scored{blk, score})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
sort.Slice(hits, func(i, j int) bool {
|
||||
if hits[i].score != hits[j].score {
|
||||
return hits[i].score > hits[j].score
|
||||
}
|
||||
return hits[i].blk.Seq > hits[j].blk.Seq
|
||||
})
|
||||
if len(hits) > limit {
|
||||
hits = hits[:limit]
|
||||
}
|
||||
out := make([]recallBlock, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = h.blk
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recallTokens éclate une chaîne en un ensemble de mots-clés minuscules (>= 2
|
||||
// caractères), en coupant sur tout ce qui n'est ni lettre ni chiffre.
|
||||
func recallTokens(s string) map[string]struct{} {
|
||||
out := map[string]struct{}{}
|
||||
for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
||||
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r >= 'à' && r <= 'ÿ')
|
||||
}) {
|
||||
if len([]rune(w)) >= 2 {
|
||||
out[w] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recallLabel fabrique un libellé court et lisible pour un bloc, à partir de son
|
||||
// rôle (préfixé du nom d'outil quand on le connaît) et de sa première ligne.
|
||||
func recallLabel(role, toolName, content string) string {
|
||||
head := ""
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if s := strings.TrimSpace(line); s != "" {
|
||||
head = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if r := []rune(head); len(r) > 70 {
|
||||
head = strings.TrimSpace(string(r[:70])) + "…"
|
||||
}
|
||||
tag := role
|
||||
if role == "tool" && toolName != "" {
|
||||
tag = toolName
|
||||
}
|
||||
if head == "" {
|
||||
return tag
|
||||
}
|
||||
return tag + ": " + head
|
||||
}
|
||||
|
||||
// toolRecall ramène un bloc archivé pour le modèle (outil recall).
|
||||
func toolRecall(args map[string]any) string {
|
||||
id, _ := args["id"].(string)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return "[erreur] id manquant"
|
||||
}
|
||||
blk, ok := recallGet(id)
|
||||
if !ok {
|
||||
return fmt.Sprintf("[erreur] bloc %q introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés", id)
|
||||
}
|
||||
return fmt.Sprintf("[%s — %s, rappelé verbatim]\n%s", blk.ID, blk.Label, blk.Content)
|
||||
}
|
||||
|
||||
// toolRecallSearch retrouve des blocs par mots-clés (outil recall_search).
|
||||
func toolRecallSearch(args map[string]any) string {
|
||||
q, _ := args["query"].(string)
|
||||
if strings.TrimSpace(q) == "" {
|
||||
return "[erreur] query manquante"
|
||||
}
|
||||
lim := 0
|
||||
if v, ok := args["limit"].(float64); ok {
|
||||
lim = int(v)
|
||||
}
|
||||
hits := recallSearch(q, lim)
|
||||
if len(hits) == 0 {
|
||||
return "[aucun bloc archivé ne correspond]"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Blocs trouvés (rappelle le bon avec recall(id)) :\n")
|
||||
for _, h := range hits {
|
||||
fmt.Fprintf(&b, "- %s — %s\n %s\n", h.ID, h.Label, recallSnippet(h.Content, 160))
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// recallSnippet renvoie un extrait d'une ligne, borné, pour l'aperçu de recherche.
|
||||
func recallSnippet(s string, n int) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if r := []rune(s); len(r) > n {
|
||||
return strings.TrimSpace(string(r[:n])) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// recallTool / recallSearchTool : schémas annoncés au modèle en mode agent.
|
||||
func recallTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "recall",
|
||||
Description: "Bring back verbatim an earlier block archived when this conversation was compacted, from the id shown as recall:rN in the summary.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "string", "description": "Block id, e.g. \"r7\"."},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func recallSearchTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "recall_search",
|
||||
Description: "Find an archived block and its recall id by keywords, when it is not listed in the current summary. Then call recall(id).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "Keywords describing the block."},
|
||||
"limit": map[string]any{"type": "integer", "description": fmt.Sprintf("Default %d, max %d", recallSearchDefault, recallSearchMax)},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// bigBlock fabrique un contenu franchement au-dessus de recallArchiveMinLen,
|
||||
// pour qu'il soit archivé plutôt que simplement porté par le résumé.
|
||||
func bigBlock(marker string) string {
|
||||
return marker + "\n" + strings.Repeat("ligne de contenu volumineux à conserver verbatim\n", 40)
|
||||
}
|
||||
|
||||
// Un bloc archivé doit revenir MOT POUR MOT : c'est toute la promesse de recall.
|
||||
// Un résumé approximatif suffisait déjà avant ; ce qu'on ajoute ici, c'est
|
||||
// l'exactitude — un gros code ou un diff ne survit pas à une reformulation.
|
||||
func TestRecallRendVerbatim(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
content := bigBlock("MARQUEUR-UNIQUE-42")
|
||||
id, err := archiveRecallBlock("web_read: page", "tool", content)
|
||||
if err != nil {
|
||||
t.Fatalf("archivage impossible : %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(id, "r") {
|
||||
t.Fatalf("id mal formé : %q", id)
|
||||
}
|
||||
blk, ok := recallGet(id)
|
||||
if !ok {
|
||||
t.Fatalf("bloc %q introuvable juste après son archivage", id)
|
||||
}
|
||||
if blk.Content != content {
|
||||
t.Fatal("le contenu rappelé diffère de l'original : l'archive n'est pas verbatim")
|
||||
}
|
||||
// Le passage par l'outil (ce que voit le modèle) doit aussi porter le contenu.
|
||||
if got := toolRecall(map[string]any{"id": id}); !strings.Contains(got, "MARQUEUR-UNIQUE-42") {
|
||||
t.Fatalf("l'outil recall ne rend pas le bloc : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Les ids doivent être uniques et monotones : deux compactages qui se croisent ne
|
||||
// doivent jamais écraser le bloc de l'autre. NextSequence le garantit côté bbolt ;
|
||||
// ce test verrouille qu'on s'en sert bien.
|
||||
func TestRecallIDsUniquesEtMonotones(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
vus := map[string]bool{}
|
||||
var last uint64
|
||||
for i := 0; i < 5; i++ {
|
||||
id, err := archiveRecallBlock("bloc", "tool", bigBlock("contenu"))
|
||||
if err != nil {
|
||||
t.Fatalf("archivage %d : %v", i, err)
|
||||
}
|
||||
if vus[id] {
|
||||
t.Fatalf("id %q réutilisé : un bloc en écrase un autre", id)
|
||||
}
|
||||
vus[id] = true
|
||||
seq, ok := recallIDToSeq(id)
|
||||
if !ok {
|
||||
t.Fatalf("id illisible : %q", id)
|
||||
}
|
||||
if i > 0 && seq <= last {
|
||||
t.Fatalf("séquence non monotone : %d après %d", seq, last)
|
||||
}
|
||||
last = seq
|
||||
}
|
||||
}
|
||||
|
||||
// recallIDToSeq doit tolérer ce que le modèle écrit réellement : avec ou sans
|
||||
// préfixe, majuscule, espaces. Un id refusé pour un espace en trop enverrait le
|
||||
// modèle croire que son bloc a disparu.
|
||||
func TestRecallIDToSeqTolerant(t *testing.T) {
|
||||
for _, in := range []string{"r7", "R7", " r7 ", "7"} {
|
||||
if seq, ok := recallIDToSeq(in); !ok || seq != 7 {
|
||||
t.Fatalf("%q → (%d, %v), attendu (7, true)", in, seq, ok)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"", "rr", "abc", "r-1"} {
|
||||
if _, ok := recallIDToSeq(in); ok {
|
||||
t.Fatalf("%q accepté alors qu'il est invalide", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recall_search est l'issue de secours quand l'id n'est plus dans le résumé
|
||||
// courant : le bloc doit rester trouvable par mots-clés, et l'outil doit rendre
|
||||
// son id pour que le modèle puisse enchaîner sur recall(id).
|
||||
func TestRecallSearchRetrouveParMotsCles(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
if _, err := archiveRecallBlock("web_read: recette", "tool", bigBlock("gratin dauphinois")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wanted, err := archiveRecallBlock("web_read: horaires", "tool", bigBlock("le train de 14h12 part quai 3"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits := recallSearch("train horaires", 0)
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("aucun résultat pour des mots-clés pourtant présents")
|
||||
}
|
||||
if hits[0].ID != wanted {
|
||||
t.Fatalf("meilleur résultat %q, attendu %q (le libellé doit compter double)", hits[0].ID, wanted)
|
||||
}
|
||||
out := toolRecallSearch(map[string]any{"query": "train horaires"})
|
||||
if !strings.Contains(out, wanted) {
|
||||
t.Fatalf("l'outil recall_search ne rend pas l'id à rappeler : %q", out)
|
||||
}
|
||||
if n := len(recallSearch("motabsolumentabsent", 0)); n != 0 {
|
||||
t.Fatalf("%d bloc(s) remontent pour un terme absent", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Le cœur du lot : un gros bloc du torse doit être ARCHIVÉ avant compaction, et
|
||||
// l'historique compacté doit porter son id. Sans llama-server en test, le résumé
|
||||
// échoue et on tombe sur le repli dégraissé — c'est justement le chemin où
|
||||
// l'ancien code effaçait le contenu sans laisser d'adresse.
|
||||
func TestCompactArchiveLesGrosBlocs(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
page := func(n int) Message {
|
||||
return tm(bigBlock("page numéro " + string(rune('a'+n))))
|
||||
}
|
||||
msgs := []Message{um("cherche les horaires du train pour Lyon")}
|
||||
for i := 0; i < 10; i++ {
|
||||
msgs = append(msgs, atc("web_read"), page(i))
|
||||
}
|
||||
// caps.Agent : sans le mode agent, le modèle n'aurait pas l'outil recall et on
|
||||
// n'archive délibérément rien.
|
||||
out, changed := compactMessages(t.Context(), msgs, Caps{Agent: true})
|
||||
if !changed {
|
||||
t.Fatal("aucune compaction alors que le torse est plein de grosses pages")
|
||||
}
|
||||
joined := ""
|
||||
for _, m := range out {
|
||||
joined += msgText(m) + "\n"
|
||||
}
|
||||
if !strings.Contains(joined, "recall(") {
|
||||
t.Fatalf("l'historique compacté ne cite aucun bloc rappelable :\n%s", joined)
|
||||
}
|
||||
if strings.Contains(joined, compactPrunedMarker) {
|
||||
t.Fatal("un gros bloc a été effacé sans adresse de rappel alors qu'il était archivable")
|
||||
}
|
||||
// Et le contenu doit être réellement récupérable, pas seulement cité.
|
||||
hits := recallSearch("page numéro", 0)
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("les blocs cités dans l'historique ne sont pas dans l'archive")
|
||||
}
|
||||
if !strings.Contains(hits[0].Content, "ligne de contenu volumineux") {
|
||||
t.Fatal("le bloc archivé ne contient pas le texte d'origine")
|
||||
}
|
||||
}
|
||||
|
||||
// Sans mode agent, le modèle n'a pas l'outil recall : archiver produirait des ids
|
||||
// qu'il ne peut pas utiliser, et un résumé pollué de références mortes.
|
||||
func TestCompactNArchivePasSansAgent(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
msgs := []Message{um("question")}
|
||||
for i := 0; i < 10; i++ {
|
||||
msgs = append(msgs, atc("web_read"), tm(bigBlock("page")))
|
||||
}
|
||||
out, _ := compactMessages(t.Context(), msgs, Caps{})
|
||||
for _, m := range out {
|
||||
if strings.Contains(msgText(m), "recall(") {
|
||||
t.Fatal("des références recall sont proposées alors que l'outil n'est pas fourni")
|
||||
}
|
||||
}
|
||||
if len(recallSearch("page", 0)) != 0 {
|
||||
t.Fatal("des blocs ont été archivés hors mode agent")
|
||||
}
|
||||
}
|
||||
|
||||
// recallEligible décide ce qui mérite un id. Deux pièges : les petits blocs (le
|
||||
// résumé les porte très bien) et le résumé d'une compaction PRÉCÉDENTE, qu'on
|
||||
// ré-archiverait à chaque passe en faisant enfler l'archive pour rien.
|
||||
func TestRecallEligible(t *testing.T) {
|
||||
gros := strings.Repeat("x", recallArchiveMinLen+1)
|
||||
if !recallEligible(tm(gros)) {
|
||||
t.Fatal("un gros résultat d'outil doit être archivé")
|
||||
}
|
||||
if recallEligible(tm("court")) {
|
||||
t.Fatal("un petit bloc ne mérite pas un id : le résumé le porte")
|
||||
}
|
||||
if recallEligible(Message{Role: "system", Content: gros}) {
|
||||
t.Fatal("un message système n'a rien à faire dans l'archive")
|
||||
}
|
||||
if recallEligible(um(compactSummaryPrefix + " " + gros)) {
|
||||
t.Fatal("le résumé d'une compaction précédente serait ré-archivé à chaque passe")
|
||||
}
|
||||
}
|
||||
|
||||
// Garde-fou anti-résumé-dégénéré : un petit modèle mal aiguillé répond parfois
|
||||
// par un simple « recall:r7 » au lieu d'écrire de la prose. Accepter ça, c'est
|
||||
// remplacer tout le passé de la conversation par une référence vide.
|
||||
func TestSummaryLooksEmpty(t *testing.T) {
|
||||
vrai := "L'utilisateur cherche les horaires du train pour Lyon ; le train de 14h12 part quai 3, reste à confirmer le tarif."
|
||||
if summaryLooksEmpty(vrai) {
|
||||
t.Fatalf("un vrai résumé est rejeté : %q", vrai)
|
||||
}
|
||||
for _, degenere := range []string{"", " ", "recall:r7", "recall:r7 recall:r8 recall:r9", "voir recall(r7)."} {
|
||||
if !summaryLooksEmpty(degenere) {
|
||||
t.Fatalf("résumé dégénéré accepté : %q", degenere)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// compactSummaryUserMsg porte l'index des ids rappelables. Il doit rester borné
|
||||
// à la passe courante : sinon une conversation infinie ferait enfler cette
|
||||
// section jusqu'à annuler le bénéfice de la compaction.
|
||||
func TestCompactSummaryUserMsg(t *testing.T) {
|
||||
sans := compactSummaryUserMsg("résumé quelconque", nil)
|
||||
if !strings.HasPrefix(sans, compactSummaryPrefix) {
|
||||
t.Fatal("le message de compaction doit être reconnaissable à son préfixe")
|
||||
}
|
||||
if strings.Contains(sans, "recall(") {
|
||||
t.Fatal("recall est proposé alors qu'aucun bloc n'a été archivé")
|
||||
}
|
||||
avec := compactSummaryUserMsg("résumé quelconque", []recallEntry{{id: "r7", label: "web_read: horaires"}})
|
||||
if !strings.Contains(avec, "r7") || !strings.Contains(avec, "web_read: horaires") {
|
||||
t.Fatalf("l'index des blocs rappelables est absent :\n%s", avec)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -118,6 +117,11 @@ var (
|
||||
)
|
||||
|
||||
func engineSeesImages() bool {
|
||||
// Preset externe : aucun /props à sonder, on s'en tient à la déclaration
|
||||
// du preset (case « le modèle accepte les images »).
|
||||
if externalActive() {
|
||||
return externalVisionActive()
|
||||
}
|
||||
visionProbeMu.Lock()
|
||||
defer visionProbeMu.Unlock()
|
||||
if time.Since(visionProbeAt) < 10*time.Second {
|
||||
@@ -154,6 +158,12 @@ func engineSeesImages() bool {
|
||||
// où un base64 de plusieurs dizaines de milliers de tokens était rejoué à
|
||||
// chaque tour jusqu'à dépasser définitivement le contexte ;
|
||||
// - garantir l'invariant à l'avenir, quel que soit le chemin d'écriture.
|
||||
//
|
||||
// Le texte restant reçoit imageLostMarker : sans lui, une conversation rouverte
|
||||
// ne garde que la légende (« Capture … », « Image demandée (x.png) : ») et le
|
||||
// modèle n'a plus aucun indice qu'il A VU une image — il la redécrit de mémoire
|
||||
// au lieu d'y revenir. Même raison que dans msgText, autre bout de la chaîne.
|
||||
// Vaut pour toutes les provenances : capture, pièce jointe, see_image.
|
||||
func stripImageParts(msgs []Message) []Message {
|
||||
for i, m := range msgs {
|
||||
parts, ok := m.Content.([]any)
|
||||
@@ -176,7 +186,7 @@ func stripImageParts(msgs []Message) []Message {
|
||||
}
|
||||
}
|
||||
if dropped {
|
||||
msgs[i].Content = strings.Join(texts, "\n")
|
||||
msgs[i].Content = strings.Join(texts, "\n") + imageLostMarker
|
||||
}
|
||||
}
|
||||
return msgs
|
||||
@@ -207,11 +217,13 @@ func screenshotImageMessage(relPath string) (Message, bool) {
|
||||
if err != nil || len(b) == 0 {
|
||||
return Message{}, false
|
||||
}
|
||||
// Redimensionnement sous maxImageDim avant l'envoi : une capture de page
|
||||
// fait facilement 1500×4000 px, dont le base64 pèse plus que ce que le
|
||||
// projecteur regarde (voir prepareImageForModel, web_upload_orient.go).
|
||||
b, mime = prepareImageForModel(b, mime)
|
||||
return Message{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "Voici la capture demandée."},
|
||||
{"type": "image_url", "image_url": map[string]any{
|
||||
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
|
||||
}},
|
||||
imageURLPart(b, mime),
|
||||
}}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,9 @@ func TestScreenshotSuitLEtatDeLaVision(t *testing.T) {
|
||||
// Un base64 d'image persisté dans l'historique est rejoué à chaque tour : vu en
|
||||
// production, 55 000 tokens de requête pour 32 768 de contexte — plus aucun
|
||||
// tour ne passait. stripImageParts guérit les conversations existantes en
|
||||
// retirant les parties image et en aplatissant le texte restant.
|
||||
// retirant les parties image et en aplatissant le texte restant — en laissant
|
||||
// imageLostMarker, pour que le modèle sache qu'une image A ÉTÉ montrée et qu'il
|
||||
// doit en reprendre une plutôt que la redécrire de mémoire.
|
||||
func TestStripImagePartsGueritLHistorique(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "user", Content: "bonjour"}, // simple chaîne : intouchée
|
||||
@@ -153,8 +155,40 @@ func TestStripImagePartsGueritLHistorique(t *testing.T) {
|
||||
t.Fatalf("message texte modifié : %#v", out[0].Content)
|
||||
}
|
||||
got, ok := out[1].Content.(string)
|
||||
if !ok || got != "Voici la capture demandée." {
|
||||
t.Fatalf("le message multimodal doit être aplati en texte sans l'image, obtenu %#v", out[1].Content)
|
||||
if !ok || got != "Voici la capture demandée."+imageLostMarker {
|
||||
t.Fatalf("le message multimodal doit être aplati en texte, sans l'image mais avec le marqueur de perte, obtenu %#v", out[1].Content)
|
||||
}
|
||||
if strings.Contains(got, "base64") {
|
||||
t.Fatalf("le base64 de l'image ne doit plus apparaître : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// msgText est le point de passage de TOUTE la chaîne de compaction (estimation
|
||||
// du contexte, transcript donné au résumeur). S'il n'extrait que les parties
|
||||
// `text`, un message qui porte une capture ne pèse que sa légende et le résumeur
|
||||
// ignore jusqu'à l'existence de l'image : elle disparaît sans laisser de trace.
|
||||
func TestMsgTextSignaleLaPresenceDUneImage(t *testing.T) {
|
||||
// Message VIVANT tel que construit par screenshotImageMessage ([]map[string]any).
|
||||
live := Message{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "Capture demandée :"},
|
||||
{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64,AAAA"}},
|
||||
}}
|
||||
// Même message RELU depuis le JSON persisté ([]any de map génériques).
|
||||
reloaded := Message{Role: "user", Content: []any{
|
||||
map[string]any{"type": "text", "text": "Capture demandée :"},
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64,AAAA"}},
|
||||
}}
|
||||
for name, m := range map[string]Message{"vivant": live, "relu": reloaded} {
|
||||
got := msgText(m)
|
||||
if !strings.Contains(got, "Capture demandée :") {
|
||||
t.Fatalf("%s : le texte de la légende doit être conservé, obtenu %q", name, got)
|
||||
}
|
||||
if !strings.Contains(got, imageLostMarker) {
|
||||
t.Fatalf("%s : la présence de l'image doit être signalée, obtenu %q", name, got)
|
||||
}
|
||||
if strings.Contains(got, "base64") {
|
||||
t.Fatalf("%s : le base64 ne doit jamais entrer dans le transcript : %q", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 274 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user