Compare commits

...
25 Commits
Author SHA1 Message Date
MichaelandClaude Opus 5.5 b7d7bfbe59 Interface : corrections de relecture du lot 2 — « Optimiser » seulement dans l'éditeur du preset en service
La ligne de l'optimiseur s'affichait dans l'éditeur de TOUT preset local, y
compris un preset neuf ou un autre que celui en service, alors que la mesure
porte toujours sur le preset actif (le README le dit) : un clic depuis
l'éditeur d'un autre preset aurait optimisé et proposé d'écrire celui en
service.

- La dernière liste des presets est gardée ; la ligne n'apparaît que si le
  preset ouvert est l'actif, et local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:33:10 +02:00
MichaelandClaude Opus 5.5 568f344273 Raisonnement : corrections de relecture du lot 2 — un refus de gabarit reconnu sans faux positif
Avec REASONING_ECHO, tout 400/500 dont le corps contenait « thinking » passait
pour un refus du raisonnement renvoyé, et ce test passe avant le filet des
outils. Une erreur d'analyse d'appel d'outil cite le texte du modèle : s'il y
écrivait « thinking », le tour était rejoué sans raisonnement et, si la
relance passait par chance, la clé était coupée pour ce modèle jusqu'au
redémarrage.

- « Failed to parse input … » (appel d'outil mal formé) n'est jamais un refus
  du gabarit : le filet des outils s'en charge, comme sans la clé.
- « thinking » ne compte que dans une erreur du gabarit (jinja, template) ;
  raise_exception et les messages invalides restent reconnus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:32:20 +02:00
MichaelandClaude Opus 5.5 5f00b6f041 Contexte : corrections de relecture du lot 2 — seul un bloc posé par Loki est retiré, jamais un texte tapé
Les blocs <context_update> de PROJ_SNAPSHOT étaient reconnus à leur seul
préfixe de texte, et le nettoyage tournait AUSSI sans la clé (requête,
historique persisté, titre, tâche du vérificateur, export, résumé). Un message
de l'utilisateur qui commençait par cette balise — un journal de requête collé
par qui travaille sur Loki — perdait son début en silence : le défaut n'était
plus identique à l'octet.

- Message.CtxUpd marque le message où Loki a posé un bloc (prependCtxUpdate),
  comme ImgRelay pour les relais d'image ; seuls les messages marqués sont
  nettoyés, et la marque tombe avec le bloc.
- La marque ne quitte jamais Loki : retirée à l'envoi avec celle des relais.
- Sans la clé, aucun message n'est marqué : rien n'est retouché.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:31:48 +02:00
MichaelandClaude Opus 5.5 39487f9449 Cache : corrections de relecture du lot 2 — KEEP_TURN_IMAGES retire les images avant tout résumé, partout
Le README promet que les images gardées partent d'abord et que le résumé
(avec perte) ne suit que s'il reste nécessaire. C'était vrai en début de tour
et entre deux étapes, pas ailleurs : la compaction de fin de tour, le filet
réactif (prompt refusé) et la fenêtre pleine en génération retiraient les
images ET résumaient d'un même geste, alors que le retrait seul suffisait.

- Fin de tour : même retrait que le début de tour, puis le seuil est relu.
- Prompt refusé pour débordement : images retirées, requête rejouée sans
  elles, avant la compaction de secours.
- finish=length : images retirées d'abord ; l'étape est rejouée sans résumé
  si le contexte libéré suffit.

Sans la clé, aucune image gardée : chemins d'avant à l'identique.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:30:14 +02:00
MichaelandClaude Opus 5.5 78d15cefbf MoE : corrections de relecture du lot 2 — LOAD_GUARD ne refuse plus sur une borne fausse, et le refus se voit
La garde du mode de chargement est le seul refus actif par défaut du lot 2 :
il ne doit tomber que sur un échec certain, et se dire là où l'on regarde.

- Serveurs --rpc (ou LLAMA_ARG_RPC) : une part des poids part ailleurs, la
  VRAM locale ne borne plus rien — avertissement, jamais de refus.
- Mac Intel : la mémoire n'est unifiée que sur Apple Silicon ; ailleurs la
  VRAM est inconnue, donc jamais de refus (l'agrandissement du cache RAM
  reste coupé sur tout macOS, comme avant).
- LLAMA_ARG_NO_MMAP / LLAMA_ARG_MLOCK restées dans l'environnement ne
  comptent plus sur un moteur à --load-mode, qui les ignore : un lancement en
  mmap n'est plus refusé pour elles.
- Le refus sortait de « loki serve » avant tout chargement : l'interface
  affichait un chargement sans fin. modelLoadError le reconnaît et dit quoi
  faire (--load-mode mmap ou LOAD_GUARD=off).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:28:57 +02:00
MichaelandClaude Opus 5.5 f0d5711a1a Slots : corrections de relecture du lot 2 — une lecture d'un client ne prend plus le slot
Les proxys de Loki (/v1 et le relais) comptaient TOUTE requête d'un client
comme une requête qui passe par le slot : un GET /v1/models ou une sonde
/health (Open WebUI en envoie régulièrement) annulait le préchauffage en vol
et retirait le tampon de la discussion. Avec PREWARM, COMPACT_CONTINUATION ou
SLOT_PERSIST, la clé devenait inopérante dès qu'un client sondait le moteur.

- engineProxyBegin : une lecture (GET, HEAD) reste comptée en vol, comme
  avant, mais n'avance pas le numéro des requêtes et garde le préchauffage ;
  une complétion de client garde le comportement d'avant.
- README : avec PREWARM, SLOT_PERSIST ne garde presque rien (le préchauffage
  passe par le slot après chaque tour) — dit plutôt que découvert.

Sans aucune de ces clés, rien ne change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:26:42 +02:00
MichaelandClaude Opus 5.5 a94c752529 Optimiseur : corrections de relecture du lot 2 — verrou périmé reconnu, application interrompue défaite
Le verrou de l'optimiseur ne doit jamais bloquer quand aucune optimisation
ne tourne, et ne jamais passer pour périmé quand elle tourne. Plusieurs
identités de processus ne tenaient pas ces deux promesses hors Windows.

- Linux : l'instant de démarrage est relatif au boot. Après une coupure, un
  service relancé au même moment du démarrage retrouvait PID et top : le
  verrou de l'ancien passait pour vivant et tout était refusé jusqu'à son
  retrait à la main. L'identifiant du boot le préfixe ; un verrou qui porte
  notre PID sans être l'un des nôtres est périmé.
- Linux : un binaire remplacé par une mise à jour (« (deleted) ») faisait
  passer le propriétaire vivant pour mort. La mise à jour est de toute façon
  refusée pendant une optimisation (interface et loki update).
- macOS : ps rend lstart dans la langue et le fuseau de l'appelant ; le web
  (launchd) et un terminal en français ne lisaient pas la même identité. LC_ALL=C
  et TZ=UTC.
- Un verrou illisible tout juste créé (vide entre création et écriture) n'est
  plus retiré : seulement après 10 s.
- loki tune : terminal fermé ou kill (SIGTERM, SIGHUP) annulent proprement,
  comme Ctrl-C ; Ctrl-C retrouve son sens aux questions de fin.
- Application : la sauvegarde est notée dans le verrou ; un Loki tué avant la
  vérification remet l'ancienne version au redémarrage. Deux clics rapprochés
  ne lancent plus deux applications. Un échec de relance du vrai moteur se
  voit dans le résultat, plus seulement au journal ; un essai qui survit à son
  arrêt reste noté dans le verrou.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:25:39 +02:00
MichaelandClaude Opus 5.5 9d02592d4f Optimiseur : relecture — slot fixe sous SIDE_SLOT, moteur mort vu tôt, verrou réécrit sous Windows
- Avec SIDE_SLOT, les tours en profondeur d'un essai restent sur le second
  slot : la reprise du cache ne dépend plus du slot choisi par le moteur
- Après application, un moteur qui meurt au chargement (OOM) ramène l'ancienne
  version en quelques secondes au lieu d'attendre le délai entier
- La réécriture du verrou (essai en cours) réessaie quand Windows refuse de
  remplacer un fichier lu au même instant

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:59:50 +02:00
MichaelandClaude Opus 5.5 9bffff78c2 Optimiseur : loki tune et bouton « Optimiser », essais sans perte sur un moteur privé
Chercher à la main le bon micro-lot, les threads ou la marge --fit d'un preset
demandait de dupliquer, basculer et comparer des bench complets un par un.
L'optimiseur le fait sur un moteur d'essai, sans jamais toucher à config.env ni
à ce que calcule le modèle, et n'écrit rien sans un clic.

- Isolation : moteur principal arrêté puis relancé ; chaque essai est une copie
  de la configuration (LOKI_HOME/tune/run), lancée par « loki serve » sur
  127.0.0.1 et un port libre, groupe de processus tué en sortie
- Verrou exclusif inter-processus (LOKI_HOME/tune.lock, PID + démarrage +
  binaire) vérifié par serviceAction start/restart et par le chat, les tâches,
  bash_bg, /v1, le bench, la bascule et l'enregistrement de preset, GPU, clé
  d'API, moteur ; essai orphelin arrêté avant tout démarrage du moteur
- Essais : lots, threads, délestage, marges --fit, files CUDA ; placement --fit
  et SPEC/CUDA_GRAPH_OPT/--backend-sampling seulement sur demande
- EXTRA_ARGS jeton par jeton, liste blanche seulement ; ligne de commande de
  chaque essai composée à blanc et comparée à la référence (« dénature »),
  contexte, slots et cache KV recontrôlés une fois chargé
- Mesure : bench complet du lot 1 à profondeur fixe ; score = durée d'un tour
  type (médianes de la télémétrie) ; gain > max(3 %, écart entre passages)
- Application sur clic : copie du preset, ou preset actuel sauvegardé, réécrit
  clé par clé, vérifié par une sonde et rétabli en cas d'échec

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:56:11 +02:00
MichaelandClaude Opus 5.5 f87c7b615b MoE : avis de placement, copie en placement auto et garde du mode de chargement
Un MoE aux experts sur CPU se règle aujourd'hui à la main (-ot, --n-cpu-moe
40, UBATCH 512) sans que Loki dise ce que --fit ferait mieux, ni qu'un
--load-mode none/mlock trop gros pour la RAM mène au swap ou à l'OOM
(llama.cpp #26110). Rien n'est réécrit en douce : des avis, une copie sur
demande, un refus seulement quand l'échec est certain.

- moeNotes (pure, comme nglArgs) : experts placés à la main → --fit ne les
  place pas, et ne pas monter UBATCH seul (tampon plus gros sur chaque carte) ;
  placement auto, MoE plus gros que la VRAM, UBATCH ≤ 512 → « UBATCH 2048+ » ;
  moteur sans --fit → « garde --n-cpu-moe » ; --fit coupé par un autre -ot.
  Le détecteur cpuExperts ne compte un -ot que s'il vise les experts (exps)
  vers le CPU : celui de per_layer_token_embd n'en est pas.
- « Dupliquer en placement auto… » (éditeur, /api/preset/autoplace) : COPIE du
  preset affiché, sans -ot des experts, --n-cpu-moe, --cpu-moe, -ngl,
  --tensor-split, --fit off, -b/-ub ni drapeaux de chargement ; NGL retiré,
  UBATCH 2048, BATCH 4096, --load-mode mmap ; CTX, cache KV, échantillonnage
  intacts. Refusée si --fit resterait inactif ou si CTX vaut 0. L'original
  n'est pas touché, la copie n'est pas activée.
- loadModeRisk : none, mlock, dio, mmap+mlock (et --no-mmap, --mlock,
  LLAMA_ARG_*) face à la RAM effective (limite cgroup comprise), en comptant
  modèle moins VRAM (tout le modèle sur macOS) : avertissement au-delà de
  80 %, refus au lancement au-delà de 90 % de la borne basse seulement ;
  LOAD_GUARD=off le lève. VRAM inconnue : jamais de refus.
- Éditeur : avis sous « Experts MoE sur CPU » et sous « Chargement ».

Ligne de commande et environnement du moteur inchangés (test de
non-régression) ; presets externes ignorés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:12:56 +02:00
MichaelandClaude Opus 5.5 986a636c20 Placement : liaison PCIe des cartes et guide d'ordre dans l'éditeur, en conseil
Deux cartes inégales (5060 Ti + 3060) se placent par deux règles du moteur
qui peuvent s'opposer : --fit remplit d'abord la DERNIÈRE carte, qui porte
la couche de sortie, et les experts MoE en RAM sont recopiés au prefill vers
la PREMIÈRE. Pour choisir l'ordre il faut voir la liaison de chaque carte ;
l'éditeur n'en montrait rien.

- detectGPUs lit aussi la liaison PCIe (génération et largeur, actuelles et
  maximales), le bus et l'horloge mémoire max, dans la même invocation de
  nvidia-smi. « [N/A] » vaut zéro ; un pilote qui refuse un champ fait
  retomber sur la requête historique (jamais de GPU perdu), un délai dépassé
  n'est pas relancé. Borné à 10 s. La largeur du bus mémoire n'existe pas
  dans nvidia-smi : absente.
- /api/backends/devices : une seule lecture nvidia-smi par énumération
  complète mémoire manquante ET liaison (annotateDevices, pure), par nom de
  carte, CUDA seulement, rien pour deux cartes homonymes. La lecture des
  jauges (gpuStatsCached) n'est pas touchée.
- Éditeur : liaison max par carte (l'actuelle et l'horloge en info-bulle),
  guide de placement dans l'ordre du moteur (--device compris), marges
  FIT_TARGET carte par carte, signalement d'un --tensor-split ou d'un
  CUDA_VISIBLE_DEVICES propre au preset, lien « inverser l'ordre » ; l'ordre
  choisi survit aux cases cochées.
- Pas de bouton de mesure : comparer deux ordres demande deux rechargements
  et un ordre inversé peut manquer de VRAM — le guide explique la marche à
  suivre (copie du preset, bench complet sur chacun).
- loki gpu affiche la liaison.

Rien ne change dans la ligne de commande du moteur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:03:02 +02:00
MichaelandClaude Opus 5.5 889a85888d Persistance : un résultat d'outil supprimé pendant son écriture ne renaît plus
TestToolResultsSaveLoadDelete échouait de temps en temps en suite
complète (1 fois sur ~300 en isolé). Vraie course dans le code, pas dans
le test : deleteToolResultsFor retirait les résultats EN ATTENTE de
l'écrivain différé, mais pas ceux qu'il avait déjà pris dans son lot.
Si la transaction de suppression passait avant celle de l'écrivain, le
résultat était écrit juste après avoir été effacé, et renaissait.

En production, la suppression d'une discussion passe d'abord par
forget + flush, ce qui masquait le problème ; mais forget non plus ne
pouvait rien contre un lot déjà parti.

- l'écrivain note les résultats du lot en vol ; dropToolRes (donc
  forget et deleteToolResultsFor) les marque annulés
- l'écrivain vérifie l'annulation DANS sa transaction ; la suppression
  annule AVANT d'ouvrir la sienne : bbolt sérialisant les deux, soit le
  résultat est sauté, soit il est écrit puis effacé
- annulations oubliées à la fin du lot : un nouveau résultat s'écrit
- test déterministe (écrivain retenu en plein lot) qui échouait avant
  la correction ; suite complète passée 3 fois, TestToolResults ×400

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:50:00 +02:00
MichaelandClaude Opus 5.5 5954d75118 Placement : SPLIT_MODE=tensor, parallélisme de tenseurs entre cartes, en opt-in
En découpe par couches (défaut), chaque carte traverse ses couches à son
tour. En mode tensor, chaque couche est coupée entre les cartes, qui
additionnent ensuite leurs morceaux : décodage plus rapide d'après
l'amont, prefill nettement plus lent. Sur deux GeForce en PCIe et une
boucle d'agent qui relit beaucoup, rien n'est acquis : expérimental,
off par défaut, et une longue liste de refus propres plutôt qu'un
moteur qui meurt en boucle. Sans la clé, ligne et environnement
identiques à l'octet près (test).

- détection : la liste littérale {none,layer,row,tensor} dans l'aide du
  moteur — « tensor » seul y est partout (--tensor-split) ; absente =
  aucun compagnon posé, note au journal
- GGML_CUDA_ALLREDUCE=internal sauf choix de l'environnement : NCCL
  compresse en BF16 les réductions du prefill (avec perte) ; nccl
  gardé s'il est posé, avec un avertissement ; note NCCL indisponible /
  P2P à poser soi-même
- -ngl all, -ts au prorata de la VRAM totale (--list-devices, ordre du
  moteur, --device compris), -fa on : chacun seulement si EXTRA_ARGS ou
  une LLAMA_ARG_* ne l'a pas déjà ; CTX explicite exigé
- refus : KV autre que f16/bf16/f32 (KV_TYPE*, -ctk/-ctv, LLAMA_ARG_*,
  jamais réécrit), --backend-sampling, -fa off, -sm à la main, poids ou
  KV sur CPU, NGL partiel, SPEC, SIDE_SLOT, une seule carte ou non CUDA,
  architectures exclues par llama.cpp et qwen4exp, preset externe
- pas de --fit en mode tensor : estimation poids + cache de CTX jetons
  contre 90 % de la VRAM, refus au-delà, CTX jamais réduit
- files CUDA, CUDA_GRAPH_OPT et FIT_TARGET voient le -sm tensor ;
  SLOT_PERSIST refusé avec la clé ; diagnostic du journal pour les
  échecs propres au mode tensor
- README et configTemplate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:35:16 +02:00
MichaelandClaude Opus 5.5 539e36ea49 Spéculation : SPEC=ngram et mtp+ngram, n-grammes du contexte en opt-in
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-mod
de llama.cpp y cherche la suite des 24 derniers jetons et propose d'un
coup les 48 à 64 suivants, sans modèle brouillon. La clé SPEC (off par
défaut, inchangé) gagne deux valeurs : ngram, et mtp+ngram qui y ajoute
la tête MTP. Même vérification exacte que MTP : distribution inchangée,
mais pas le même texte au bit près (vérification par lots), donc on
compare des sessions rejouées, pas des diffs.

- forme explicite seulement : --spec-type ngram-mod
  --spec-ngram-mod-n-match 24 --spec-ngram-mod-n-min 48
  --spec-ngram-mod-n-max 64 ; jamais --spec-default (TODO amont, contenu
  susceptible de changer avec le moteur)
- garde-fou d'aide sur « --spec-ngram-mod-n-match » (pas le simple mot
  ngram-mod, encore listé par les moteurs d'avant le renommage) ; absent =
  SPEC ignoré, note au journal
- mtp+ngram : --spec-type draft-mtp,ngram-mod seulement avec draft-mtp
  dans l'aide ET une tête MTP réelle (tenseur nextn ou MODEL_DRAFT) ;
  sinon n-grammes seuls, avec la raison — jamais « failed to create MTP
  context » en boucle
- EXTRA_ARGS/LLAMA_ARG_* : mêmes exclusions qu'avant, plus --spec_type et
  tout --spec-ngram-* (--spec-type s'additionne sans prévenir) ; pour
  ngram seul, un --spec-draft-* fait aussi taire Loki
- poids sur CPU (experts MoE, -ot, NGL partiel) : refusé tant que
  GGML_OP_OFFLOAD_MIN_BATCH (env ou OP_OFFLOAD_MIN_BATCH) ne dépasse pas
  le lot de vérification de 65 jetons, avec la suggestion
  OP_OFFLOAD_MIN_BATCH=128 ; avertissements pour un modèle plus gros que
  la RAM, les points de reprise d'un hybride, NGL imposé (logits)
- avec ngram, une tête MTP ou MODEL_DRAFT inutilisés sont signalés
- --spec-synth-len/--spec-synth-rates (et LLAMA_ARG_SPEC_SYNTH_LEN) :
  acceptation au hasard, avertissement au lancement ; Loki ne les pose
  jamais
- télémétrie : /api/perf/summary donne draft_n, draft_accepted et
  draft_rate par nature de requête
- UI : deux options du sélecteur de décodage spéculatif ; README et
  configTemplate
- tests : forme explicite, garde d'aide, repli de mtp+ngram, exclusions,
  seuil d'offload, ligne inchangée sans SPEC, acceptation par nature

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:28:25 +02:00
MichaelandClaude Opus 5.5 28ba44eb46 Slots : SLOT_PERSIST garde l'état de la discussion à la bascule de preset, en opt-in
Passer de A à B puis revenir à A relance llama-server deux fois, et la
conversation de A est recalculée en entier au retour. Avec SLOT_PERSIST=on
(off par défaut), Loki demande au moteur d'écrire l'état du slot 0 juste
avant la bascule, et le recharge au retour avant le premier message de la
discussion — seulement si tout concorde, sinon calcul normal, en silence.

llama.cpp ne vérifie à la relecture que types et tailles : rien ne dit quel
build ni quels réglages ont calculé ce cache. La clé de validité est donc
l'empreinte de tout ce qui touche au calcul, prise par « loki serve » au
lancement : ligne de commande complète (modèle, CTX, types KV, NGL, lots,
gabarit, SPEC, EXTRA_ARGS…), LLAMA_ARG_*/GGML_*/CUDA_*, taille et date du
modèle et de ses tranches, du projecteur, du brouillon, du binaire et de
ses bibliothèques. Pas de cas « mise à jour du moteur » : elle change la clé.

- --slot-save-path posé aussi pour SLOT_PERSIST accepté, sous les mêmes
  conditions de sécurité que l'isolation du lot 1 (boucle locale ou clé
  d'API) ; refusé avec le décodage spéculatif (le save ne garde pas le
  brouillon ; /slots speculative revérifié à chaque fois), des poids sur
  CPU, un --slot-save-path à la main ; la ligne reste alors celle d'avant
- sauvegarde depuis l'interface ou une tâche, avant l'arrêt : le slot doit
  porter un tour de la discussion (tampon d'engineMarkMain), ≥ 4096 jetons,
  ≤ 8 Gio estimés, double de place libre ; nom provisoire, renommé
  seulement si aucune requête n'est partie pendant l'écriture
- rechargement avant la première requête de la discussion (ou son
  préchauffage) : même clé de moteur, même discussion, slot 0 vierge (/slots
  sans id_task) ; une seule tentative, fichier retiré ; toute erreur =
  calcul normal
- deux fichiers au plus ; clé retirée = tout effacé au lancement suivant ;
  SLOT_PERSIST préservé à la bascule comme HOST (sinon B effaçait l'état de A)
- README : SIDE_SLOT, le cache KV plein ne déclenche jamais de compaction
- tests : plan et refus, ligne inchangée, empreinte (clé d'API exclue,
  fichiers et réglages inclus), rotation, séquence save puis restore sur un
  faux moteur, refus à la sauvegarde et au rechargement, autre clé, défaut
  sans aucun appel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:17:08 +02:00
MichaelandClaude Opus 5.5 7a57322a62 Slots : SIDE_SLOT ouvre un second slot pour les travaux annexes, en opt-in
En --parallel 1, une vérification, un sous-agent, une tâche ou un bench
prennent le slot de la discussion : son état part dans le cache RAM et en
revient, ou se recalcule s'il n'y tient plus. Avec SIDE_SLOT=on (off par
défaut), le moteur ouvre deux slots et la discussion garde le sien.

Dimensionnement choisi pour qu'un débordement concurrent soit impossible par
construction : cache KV NON unifié (--no-kv-unified) et -c 2×CTX, soit deux
flux séparés de CTX jetons. Sous cache unifié, les deux slots partagent le
pool et, plein, llama.cpp renvoie « Context size has been exceeded. » aux
deux — que Loki prenait pour un débordement de la conversation. Chaque slot
garde la fenêtre entière : ctxWindow et la compaction restent sur CTX, rien
n'est raccourci. --cache-idle-slots ne vide les slots au repos que sous cache
unifié : rien à régler.

- lancement : refus (un slot, ligne d'avant, note au journal) si modèle + deux
  états dépassent 90 % de la VRAM, VRAM ou GGUF inconnus, poids sur CPU,
  moteur sans --no-kv-unified, PARALLEL≠2, ou -c/-np/-kvu/--no-kv-unified/
  --kv-unified-per-slot/--cache-idle-slots dans EXTRA_ARGS (ou leurs
  LLAMA_ARG_*) ; note de VRAM en plus, avertissement si NGL imposé
- routage id_slot seulement si /props annonce 2 slots d'au moins CTX jetons :
  0 pour le tour, ses étapes, le préchauffage et le résumé en continuation ;
  1 pour vérification, sous-agents, tâches, bench, résumé sur transcription
- clients /v1 (proxy et relais) : corps réécrit en id_slot 1, quel qu'il soit
- cohérence lot 1 : l'effacement de slot s'abstient (2 slots) ; une requête
  du slot 1 n'avance pas le numéro d'engineSlotHolds, la continuation de
  compaction reste possible après une vérification
- « Context size has been exceeded. » sans nombre de jetons, deux slots en
  service : requête rejouée telle quelle, jamais compaction ni réduction
- préchauffage permis sur les deux slots de SIDE_SLOT, vers le slot 0
- éditeur de preset : VRAM du second slot ou raison du refus
- tests : ligne identique sans la clé, chaque conflit refuse sans changer la
  ligne, routage par nature, aucun id_slot sur un slot / preset externe /
  fenêtre partagée, rejeu sans compaction, proxy forcé, préchauffage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:02:40 +02:00
MichaelandClaude Opus 5.5 c1d3757f23 Cache : KEEP_TURN_IMAGES, relecture — budget plein vérifié avant de ranger l'image
Le relais rangeait l'image sur disque avant de regarder si le budget des
images gardées était déjà plein : un fichier écrit pour rien, élagué 24 h
plus tard.

- budget vérifié d'abord ; plein, l'image reste éphémère sans être rangée
- README : sur un modèle hybride, le gain dépend des points de reprise du
  moteur après une image, à vérifier dans la télémétrie avant d'adopter la clé
- test : budget plein, rien d'écrit dans chatimg

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:49:06 +02:00
MichaelandClaude Opus 5.5 f74f64ed90 Cache : KEEP_TURN_IMAGES garde les images d'outils et NUDGE_IN_TOOL range le rappel de budget, en opt-in
Une capture ou une image vue par see_image ne vivait que le temps du tour :
au tour suivant elle manquait à l'historique, le préfixe en cache divergeait
à elle et toute la boucle d'outils qui suivait était recalculée. Sur Qwen3.5,
le rappel de budget en message user faisait de même pour le tour en cours :
le nouveau message devient la « dernière question » et tout le tour est rendu
autrement. Deux clés, off par défaut et étiquetées zone grise ; sans elles,
requêtes identiques à l'octet près (testé).

- KEEP_TURN_IMAGES : image gardée par référence (chatimg, chiffrée si la
  mémoire l'est), relue à l'identique avant d'être gardée, vision active
  revérifiée à chaque tour ; preset externe seulement s'il déclare la vision
- coût mesuré par le moteur (écart de prompt_tokens moins le texte ajouté) ;
  image non mesurable = éphémère comme avant ; total sous 10 % de la fenêtre,
  la première qui dépasse et les suivantes du tour restent éphémères
- compaction, réduction forcée et début ou milieu de tour au seuil retirent
  ces images d'abord (légende et imageLostMarker gardés), le résumé ne suit
  que s'il reste nécessaire ; clé retirée ou vision perdue : retirées au tour
- relais marqué ImgRelay (persisté, retiré à l'envoi) : ni demande réinjectée
  ni preuve de demande servie, fil rouvert compris ; une image non gardée
  n'entre plus dans l'historique par une compaction en cours de tour
- discussion seulement (ni tâche, ni sous-agent, ni vérification) ; fichier
  d'image rafraîchi à chaque usage, l'élagage de 24 h ne le prend pas
- NUDGE_IN_TOOL : rappel de budget au bout du dernier résultat d'outil, même
  message persisté ; moteur local, sonde de gabarit « préfixe instable » pour
  ce modèle ; sinon, ou forme inattendue, message à part comme avant

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:46:01 +02:00
MichaelandClaude Opus 5.5 b0c53e32af Compaction : COMPACT_CONTINUATION, relecture — un refus mémorisé ne vaut que pour le même fil
Le refus mémorisé ne regardait que la taille du contexte. Il n'est évalué
qu'au-dessus du seuil : une discussion vidée, éditée ou régénérée qui
remontait dans la même plage de jetons voyait sa compaction sautée sur la foi
d'un refus qui concernait un autre fil.

- le refus garde l'empreinte de l'historique (celle de perfPrefix) ; seul un
  historique qui prolonge celui d'alors en profite, tout autre retente
- testé : historique modifié ou raccourci, refus noté par une vraie
  compaction, filet réactif jamais bloqué

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:25:35 +02:00
MichaelandClaude Opus 5.5 2097c9f915 Compaction : COMPACT_CONTINUATION résume dans le prolongement du prompt en cache, en opt-in
Le résumé d'une compaction part aujourd'hui dans une requête à part — prompt
du résumeur et transcription des anciens tours — que le moteur local calcule à
froid : des dizaines de secondes sur un 27B, des minutes sur un MoE, à chaque
compaction. Nouvelle clé COMPACT_CONTINUATION (off par défaut) : quand le slot
porte encore le prompt de la discussion, la requête de résumé est celle du tour
telle qu'elle est partie, suivie d'une seule demande de résumé ; le moteur ne
calcule qu'elle. Sans la clé, requêtes de compaction identiques à l'octet près
(prompt du résumeur comparé à une copie figée, testé).

- vue MODÈLE pour tout ce qui est rangé : bornes, archives recall, demande
  réinjectée, garantie de réduction et sortie ne changent pas ; la vue
  d'envoi (turnViewDry, wireMessages, buildChatPayload) ne sert qu'à la
  requête, rien d'injecté n'entre dans l'historique (testé)
- frontière recalée sur la vue envoyée (messages non système un pour un,
  vérifiés rôle par rôle), désignée par le nombre de messages gardés et le
  début du premier ; tout résumer avant, dater l'avancement à la frontière
- mêmes règles de résumé (+ mode Code), même budget, température 0.2 sans
  l'échantillonnage du preset, enable_thinking=false ajouté aux arguments du
  tour, reasoning_effort du tour, tool_choice « none », sans flux
- messages utilisateur de fin hors de la vue : pas en cache, et pas deux
  `user` d'affilée pour les gabarits stricts
- slot vérifié : tampon posé par une étape de tour acceptée (llm_slots.go,
  sous le verrou du compteur en vol), perdu dès qu'une autre requête part —
  vérification, sous-agent, tâche, préchauffage, résumé, bench, /v1 — ou que
  le modèle ou la fenêtre changent ; relu à l'envoi
- marge en jetons réels (dernier compte + non vu + demande + budget + 5 %)
- repli sur la transcription au moindre écart : refus du moteur, réseau,
  appel d'outil émis (tool_calls décodés) ou écrit en texte, raisonnement
  seul, résumé vide ; refus du gabarit ou appel d'outil = suspendue pour le
  modèle jusqu'au redémarrage, deux échecs de suite aussi
- avec la clé : pas de résumé quand même un vide ne réduirait pas de 20 %
  (borne exacte, avant tout archivage) ; après un refus faute de réduction,
  pas de nouvel essai avant +10 % de contexte, jamais à 90 % de la fenêtre,
  oublié quand le contexte baisse
- réactif, fenêtre pleine, bouton manuel, tâches, sous-agents, terminal,
  preset externe : chemin d'avant ; télémétrie kind=compact avec la discussion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:21:57 +02:00
MichaelandClaude Opus 5.5 eb58f7982f Cache : PREWARM, relecture — commentaire du projet forcé rendu à son appel
Le différé du préchauffage s'était glissé entre le commentaire du projet forcé
et setProjectOverride : il passe au-dessus, avec le sien.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:04:23 +02:00
MichaelandClaude Opus 5.5 630e84cc08 Cache : PREWARM prépare le prochain tour pendant que l'utilisateur lit, en opt-in
Une compaction, un dernier message rendu autrement au tour suivant ou une
tâche planifiée qui a pris le slot laissent au message suivant un recalcul
inévitable : des secondes sur un 27B, des dizaines sur un MoE aux experts en
RAM, avant le premier mot. Nouvelle clé PREWARM (off par défaut) : dès que le
moteur local est libre, Loki lui envoie la requête du prochain tour suivie
d'un message utilisateur « . », max_tokens 1, sans flux. Le moteur calcule le
préfixe et pose un point de reprise au début de ce message ; le vrai tour ne
calcule plus que le sien. Sans la clé, aucune requête de plus (testé).

- assemblage partagé, contenu vivant : turnViewDry (même code que turnView,
  sans rien toucher à la discussion, décision PROJ_SNAPSHOT via
  projSnapDecide), wireMessages, turnReasoning et buildChatPayload, extraits
  de runChat ; corps identique à l'octet près à celui d'avant (matrice
  d'options et de modes, testée contre une copie de l'ancien code)
- jamais devant un vrai travail : toute requête de Loki l'annule au départ
  (compteur de llm_slots.go, sous son verrou), sauf un tour de chat dont les
  messages sérialisés, outils et arguments du gabarit prolongent exactement
  le préfixe préchauffé
- moteur local, un seul slot (/props total_slots), pas pendant un tour, une
  tâche, un bench, un travail annexe ni une requête en vol ; un seul à la
  fois ; pas si le prochain tour compactera, ni à moins de 10 min de minuit
- requête brute : ni compaction, ni relance, ni effort appris, ni CtxUsed,
  ni stats ; erreurs lâchées (une ligne de journal par statut) ; rien persisté
- déclencheurs : fin de tour sans file d'attente, fin de tâche (projet forcé
  levé, slot effacé) ; full = aussi changement de discussion après 3 s
- capacités du dernier tour gardées en mémoire par discussion ; mode Code =
  rôle d'un message ordinaire, une demande de plan diverge et annule
- télémétrie kind=prewarm ; la vérification du cache RAM après une tâche se
  fait sur lui

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:01:57 +02:00
MichaelandClaude Opus 5.5 4932999d5c Contexte : PROJ_SNAPSHOT fige le bloc projet par discussion, ses changements arrivent en mise à jour, en opt-in
Le contexte du projet (description, index mémoire, trackers, AGENTS.md) part
en tête du premier message utilisateur, reconstruit à chaque tour : une page
créée ou une valeur de tracker notée faisait recalculer toute la conversation
derrière lui, 10 à 45k tokens. Nouvelle clé PROJ_SNAPSHOT (off par défaut) :
avec on, chaque discussion garde une copie datée du bloc, renvoyée à l'octet
près, et les changements arrivent en tête du message suivant dans un bloc
<context_update from="loki">, rangé dans l'historique. Sans la clé, la requête
est identique à l'octet près (testé contre l'ancien assemblage).

- en-têtes figés « as of <date> » pour l'index mémoire et les trackers, sans
  « answer straight from this » ; ligne fixe du système, seulement avec la clé
- mises à jour par type : +/~/- par page (clé « ](fichier) »), une ligne
  complète par tracker (clé = slug), texte COMPLET pour la description,
  AGENTS.md, ou un index dont la prose a changé — jamais de diff de prose
- état annoncé structuré et persisté (ProjSnap, omitempty), instantané pris
  sur marqueur explicite : la première page d'un projet vide arrive en mise
  à jour, le premier message ne bouge pas
- rafraîchi (blocs retirés de tout l'historique) quand le prompt change de
  toute façon : compaction début/fin/manuelle, compaction ou réduction en
  cours de tour (bloc vivant aussitôt, rangé comme instantané : pas de
  recalcul de plus), système ou outils modifiés, redémarrage de Loki, réglage
  moteur ou modèle, projet, nom, mode mémoire, mode Code, dépôt, textes des
  en-têtes ; et au-delà d'un seuil de mises à jour accumulées
- mise à jour posée sous c.mu avec l'epoch, après la compaction de début de
  tour ; texte ou multimodal ; retirée du titre, de l'export JSON, de l'entrée
  du résumeur et de la tâche du vérificateur
- loadFrom, Reset et le chargement remettent l'instantané à zéro ; clé retirée
  = nettoyage ; agent off = rien du projet ; tâches et presets externes
  inchangés ; un sous-agent ne voit pas le bloc du tour
- ordre des trackers déjà déterministe (lot 1, test existant)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:44:46 +02:00
MichaelandClaude Opus 5.5 7c9aa04fd9 Raisonnement : REASONING_ECHO renvoie au moteur local la réflexion du modèle, en opt-in
Les gabarits Qwen3.5/3.6 rendent <think>…</think> pour chaque message
assistant après la dernière question : c'est leur format entraîné. Loki ne
gardait que le texte et les appels, donc à chaque étape d'une boucle d'outils
le modèle relisait ses étapes précédentes avec des blocs vides, et le moteur
recalculait le dernier message. Nouvelle clé REASONING_ECHO (off par défaut) :
avec on, le raisonnement séparé par le serveur est gardé avec chaque message
et renvoyé au même modèle. Sans la clé, rien n'est capturé ni envoyé : la
requête est identique à l'octet près (testé).

- Message.ReasoningContent (reasoning_content) + ReasoningModel, persistés ;
  seulement depuis reasoning_content du flux, jamais depuis le découpage
  « </think> » fait chez nous (rendu en double)
- un seul point de sortie (echoMessages) : étiquette toujours retirée,
  raisonnement retiré pour une API externe, un autre modèle, un message sans
  texte ni appel, ou si la sonde dit que le gabarit ne le rend jamais
- comptage : estimateTokens/compactBounds comptent ce qui part et que le
  gabarit rend (passé seulement si la sonde dit qu'il le garde, ou l'ignore) ;
  ctxAfter ne retire plus un raisonnement renvoyé
- débordement : relance d'abord sans raisonnement, avant toute compaction ;
  shrinkToFit retire le raisonnement le plus ancien avant tout le reste ;
  le torse compacté le perd comme le résumé
- erreur de gabarit (raise_exception, thinking, Failed to parse messages) :
  relance une fois sans raisonnement avant tool_choice none / outils coupés ;
  retenu pour le modèle si la relance passe
- runBuilderTurn applique enfin NewHistory comme generate (compaction perdue
  pendant une passe de correction) ; forwardStream affiche la bannière
- sonde de gabarit : nouveau verdict renders_reasoning
- REASONING_PRESERVE (lot 1) inchangée, son interaction documentée

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:26:04 +02:00
MichaelandClaude Opus 5.5 3af481ff05 Télémétrie : une sonde du gabarit de chat, diagnostique et sans risque pour le cache
Les pistes de réutilisation du cache dépendent de deux faits que rien ne
mesurait : le gabarit du modèle rend-il encore le raisonnement d'un tour
passé, et le rendu d'un tour d'outil reste-t-il un préfixe exact quand un
message utilisateur s'y ajoute ? La sonde pose ces deux questions au moteur
local par POST /apply-template — un rendu sans état, qui ne prend ni slot ni
place dans la file — et n'en tire que des verdicts oui/non/inconnu.

Elle ne touche à aucune construction de prompt : Loki ne renvoie toujours
aucun reasoning_content au modèle. Toute évolution qui voudrait s'en servir
passera sa propre revue, et lira « unknown » comme « ne rien changer ».

- moteur local seulement ; preset externe : aucune requête, rien d'affiché
- jamais de complétion de repli : avec un slot, elle évincerait le cache
- en tâche de fond après une complétion complète du fil principal, /health
  à 200, au plus une vérification par minute ; authHeader ; 3 s par requête
- mêmes outils, chat_template_kwargs et reasoning_effort que la complétion
- add_generation_prompt=false demandé au moteur ; un moteur qui l'ignore
  donne « inconnu » plutôt qu'un faux « instable »
- tour d'outil bien formé (identifiant de 9 alphanumériques, résultat lié)
- 404, 401, exception du gabarit, délai : inconnu ; 503 relancé
- cache par build_info + modèle + empreinte du gabarit + forme de requête
- verdict dans /api/perf/summary (champ template) et une ligne [tplprobe]
  au journal par verdict nouveau

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:10:42 +02:00
89 changed files with 17149 additions and 393 deletions

No files matched your search

+369 -1
View File
@@ -135,7 +135,7 @@ un reverse proxy (`loki.mondomaine.fr`), définir une clé
`LOKI_TRUSTED_HOSTS`.
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
`logs`, `restart`, `config`, `bench`, `test`…).
`logs`, `restart`, `config`, `bench`, `tune`, `test`…).
### Données et persistance
@@ -356,6 +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
+3
View File
@@ -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:
+124 -10
View File
@@ -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
}
+88
View File
@@ -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)
}
}
+3
View File
@@ -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
+14 -2
View File
@@ -192,7 +192,10 @@ func safePresetPath(name string) (string, error) {
// CUDA_LAUNCH_QUEUES (files de lancement CUDA, voir launchQueuesEnv) suit la
// même logique : un « off » posé parce que CETTE machine bloque ne doit pas
// disparaître à la bascule suivante.
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST", "CUDA_LAUNCH_QUEUES"}
// SLOT_PERSIST (état du slot gardé à la bascule, backend_serve_persist.go) est
// par nature un réglage de bascule : posé sur A seulement, il disparaîtrait en
// passant sur B, dont le lancement effacerait l'état gardé de A.
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST", "CUDA_LAUNCH_QUEUES", "SLOT_PERSIST"}
// softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit
// pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un
@@ -203,7 +206,7 @@ var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VIS
// modèle se retrouvait entièrement sur une seule carte et mourait sur
// « cudaMalloc failed: out of memory ». Le preset explicite gagne.
// CUDA_LAUNCH_QUEUES aussi : un preset peut l'imposer pour son modèle.
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true, "CUDA_LAUNCH_QUEUES": true}
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true, "CUDA_LAUNCH_QUEUES": true, "SLOT_PERSIST": true}
// applyPresetFile installe le preset comme configuration active, en réinjectant
// les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être
@@ -264,6 +267,13 @@ func presetImposesEngine(p string) bool {
// SwitchToPreset installe le preset et redémarre le service. Les réglages
// « appareil » (preservedKeys) sont conservés à travers la bascule.
func SwitchToPreset(target string) error {
if err := tuneGuard(); err != nil {
return err
}
// SLOT_PERSIST : relevé sur la configuration d'avant, gardé juste avant
// l'arrêt (llm_slotpersist.go). Sans la clé, ou hors du process web (rien
// n'y dit ce que porte le slot) : nil, rien ne se passe.
persist := slotPersistPrepare()
if err := applyPresetFile(target); err != nil {
return err
}
@@ -277,9 +287,11 @@ func SwitchToPreset(target string) error {
if !serviceIsActive() {
return nil
}
persist.save()
return serviceAction("stop")
}
fmt.Println(dim("[info] redémarrage du service..."))
persist.save()
return serviceAction("restart")
}
+119 -10
View File
@@ -9,6 +9,7 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
@@ -259,6 +260,18 @@ func downgradeLoadMode(args []string) ([]string, string) {
// vérifie en table de tests plutôt qu'en relançant un moteur.
func cmdServe(args []string) error {
cfg := ReadConfig()
// Moteur d'ESSAI de l'optimiseur (backend_tune_run.go) : sa configuration
// est une copie posée dans le dossier de l'essai, jamais config.env, et rien
// de ce que le vrai moteur laisse derrière lui (état des slots, jeton de
// SPEC=auto) n'est touché. Sans la variable, rien ne change.
trialDir := os.Getenv(tuneTrialEnv)
if trialDir != "" {
c, err := readTuneTrialConfig(trialDir)
if err != nil {
return err
}
cfg = c
}
bin := cfg["BIN"]
if bin == "" {
return fmt.Errorf("BIN non défini — lance « loki edit »")
@@ -341,11 +354,15 @@ func cmdServe(args []string) error {
// launchQueuesEnv). Après la sélection GPU, qui fait partie de la réponse.
probeServeGPUs(cfg, extra, &si)
probeExpertEnv(&si)
probeLoadEnv(&si)
probeFidelityEnv(&si)
probeCacheRAM(cfg, extra, &si)
probeCkptEnv(&si)
probeSideSlotEnv(cfg, &si)
// SPLIT_MODE=tensor seulement : cartes listées par CE moteur, dans son ordre.
probeSplit(cfg, bin, &si)
spec := specMode(cfg)
if spec == "auto" || spec == "mtp" {
if spec != "off" && spec != "" {
probeSpec(cfg, &si)
}
// Build du moteur : seulement pour un hybride ou SPEC=auto, les seuls cas où
@@ -360,15 +377,56 @@ func cmdServe(args []string) error {
if spec == "auto" {
si.SpecAutoBlocked, specRecord = specAutoPeek(specMark)
}
probeSlotPersistEnv(cfg, &si)
if strings.Contains(si.Help, "--slot-save-path") && !hasAnyFlag(extra, "--slot-save-path") {
si.SlotDir = prepareSlotDir(LokiHome())
if trialDir != "" {
// L'essai a son propre dossier : celui du vrai moteur garde ses états.
si.SlotDir = prepareSlotDir(trialDir, false)
} else {
si.SlotDir = prepareSlotDir(LokiHome(), slotPersistOn(cfg))
}
}
llmArgs, env, notes := buildServeArgs(cfg, extra, bin, si)
applyServeEnv(env)
// SLOT_PERSIST (backend_serve_persist.go) : la clé de CE lancement, et les
// états gardés au-delà de deux retirés. Refusé pour ce preset (brouillon,
// poids sur CPU…) : ceux des autres presets restent, pour leur retour.
// Clé retirée : plus aucun état gardé sur le disque.
persistKey := ""
persistOK, _ := slotPersistPlan(cfg, extra, si)
switch {
case trialDir != "":
// Un essai ne garde rien et n'efface rien.
case persistOK:
persistKey = slotPersistKey(llmArgs, env, si, bin)
slotPersistPrune(si.SlotDir)
case !slotPersistOn(cfg):
slotPersistPurge(filepath.Join(LokiHome(), "slots"))
}
for _, n := range notes {
fmt.Fprintln(os.Stderr, "[loki serve] "+n)
}
// Chargement résident avec plus de poids en RAM que la machine n'en a :
// échec certain (voir loadModeRisk), LOAD_GUARD=off pour passer outre.
if _, refuse := loadModeRisk(cfg, llmArgs, si); refuse != "" {
return 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.
@@ -388,11 +446,17 @@ func cmdServe(args []string) error {
// Jeton de tentative : l'ancien consommé (et l'échec inscrit), le nouveau
// posé, au dernier moment — port libre, l'ancien moteur est donc bien parti
// et le process web ne peut pas le confondre avec lui.
if spec == "auto" {
if spec == "auto" && trialDir == "" {
_, _, auto := specArgs(cfg, extra, si)
specAutoSettle(specMark, si.SpecAutoBlocked, specRecord, auto)
}
// Posée au dernier moment, port libre : l'ancien moteur est parti, la clé
// lue par le process web est celle du moteur qui répondra.
if trialDir == "" {
writeSlotPersistMarker(LokiHome(), persistKey)
}
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
bin, filepath.Base(model), port)
@@ -424,6 +488,7 @@ type serveSysInfo struct {
RAMMiB int64 // RAM de la machine, limite du conteneur comprise
RAMAvailMiB int64 // RAM libre au lancement
VRAMMiB int64 // VRAM NVIDIA des cartes visibles
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
@@ -437,6 +502,10 @@ type serveSysInfo struct {
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
@@ -498,16 +567,30 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
notes = append(notes, loadNote)
}
// Parallélisme de tenseurs (SPLIT_MODE=tensor, voir backend_serve_split.go) :
// sans la clé, splitOn est faux et rien de ce qui suit ne change. Accepté, il
// compte pour les réglages qui lisent le placement dans EXTRA_ARGS (files de
// lancement, graphes CUDA, --fit) comme un -sm tensor écrit à la main.
splitOn, splitA, splitNGLArgs, splitEnv, splitNotes := splitTensorArgs(cfg, extra, si)
place := extra
if splitOn {
place = append(append([]string{}, extra...), "-sm", "tensor")
}
for k, v := range splitEnv {
env[k] = v
}
notes = append(notes, splitNotes...)
// Files de lancement CUDA : une variable d'environnement, pas un drapeau —
// la ligne de commande n'en dépend pas (voir launchQueuesEnv).
q, qNotes := launchQueuesEnv(cfg, extra, si)
q, qNotes := launchQueuesEnv(cfg, place, si)
if q != "" {
env["CUDA_SCALE_LAUNCH_QUEUES"] = q
}
notes = append(notes, qNotes...)
// Réglages d'expert passés par variable (voir backend_serve_expert.go) :
// absents du preset, ils ne posent rien.
g, gNotes := graphOptEnv(cfg, extra, si)
g, gNotes := graphOptEnv(cfg, place, si)
if g != "" {
env["GGML_CUDA_GRAPH_OPT"] = g
}
@@ -521,9 +604,16 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
// physiques au lieu de tous les threads logiques (voir threadArgs).
threads, threadNotes := threadArgs(cfg["THREADS"], cfg["THREADS_BATCH"], extra, si.CPU)
notes = append(notes, threadNotes...)
// Second slot (SIDE_SLOT, voir backend_serve_side.go) : sans la clé, sideCtx
// vaut 0 et rien de ce qui suit ne change.
sideCtx, sideMiB, sideWhy := sideSlotPlan(cfg, extra, si)
ctxArg := get("CTX", "32768")
if sideCtx > 0 {
ctxArg = strconv.Itoa(2 * sideCtx)
}
args = []string{bin,
"-m", si.Model,
"-c", get("CTX", "32768"),
"-c", ctxArg,
}
args = append(args, threads...)
args = append(args,
@@ -540,9 +630,16 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
// uniquement à cause de ce nouveau défaut. PARALLEL=n dans le preset pour qui
// veut vraiment servir plusieurs requêtes à la fois — ou --parallel dans
// EXTRA_ARGS, auquel cas on ne redouble pas le drapeau.
if !hasAnyFlag(extra, "--parallel", "-np") {
if sideCtx > 0 {
// Deux flux KV séparés de sideCtx jetons : aucun slot ne peut manger la
// place de l'autre (voir backend_serve_side.go).
args = append(args, "--parallel", "2", "--no-kv-unified")
} else if !hasAnyFlag(extra, "--parallel", "-np") {
args = append(args, "--parallel", get("PARALLEL", "1"))
}
if n := sideSlotNote(sideCtx, sideMiB, sideWhy, nglForced(cfg, extra)); n != "" {
notes = append(notes, n)
}
// Couches GPU. Quatre cas, dans cet ordre :
//
// NGL=auto → rien du tout (le moteur décide seul)
@@ -563,7 +660,9 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
// ci-dessus. Sur un moteur qui connaît « auto », 999 devient donc auto, et on
// le DIT sur stderr plutôt que de le faire en douce. Qui veut réellement
// forcer tout sur le GPU écrit NGL=all (ou un nombre qui n'est pas 999).
if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
if splitOn {
args = append(args, splitNGLArgs...) // « -ngl all », ou rien si EXTRA_ARGS l'a déjà
} else if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
ngl, note := nglArgs(get("NGL", ""), helpFitsLayersItself(si.Help))
if note != "" {
notes = append(notes, note)
@@ -572,8 +671,8 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
}
// Marge VRAM par carte du placement automatique (FIT_TARGET, voir
// fitTargetArgs) : seulement si --fit tournera vraiment.
fitt, fitNotes := fitTargetArgs(cfg, extra, si)
args = append(args, fitt...)
fitt, fitNotes := fitTargetArgs(cfg, place, si)
args = append(append(args, fitt...), splitA...)
notes = append(notes, fitNotes...)
if ktv != "" {
args = append(args, "-ctk", ktv)
@@ -628,6 +727,9 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
cram, cramNotes := cacheRAMArgs(cfg, extra, si)
args = append(append(args, cram...), slotSaveArgs(cfg, extra, si)...)
notes = append(notes, cramNotes...)
if n := slotPersistNote(slotPersistPlan(cfg, extra, si)); n != "" {
notes = append(notes, n)
}
// Points de reprise des hybrides (voir backend_serve_ckpt.go).
ck, ckNotes := ckptArgs(cfg, extra, si)
args = append(args, ck...)
@@ -647,6 +749,13 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
notes = append(notes, n)
}
notes = append(notes, lossyCacheNotes(extra, si)...)
// Experts MoE et mode de chargement (voir backend_serve_moe.go) : des notes
// seulement, la ligne ne bouge pas. Le refus d'un chargement voué à l'échec
// se décide dans cmdServe, sur cette même ligne.
notes = append(notes, moeNotes(cfg, extra, si)...)
if w, _ := loadModeRisk(cfg, args, si); w != "" {
notes = append(notes, w)
}
return args, env, notes
}
+21 -18
View File
@@ -74,8 +74,12 @@ func probeCacheRAM(cfg map[string]string, extra []string, si *serveSysInfo) {
}
}
}
// Mémoire unifiée (macOS), AMD, Vulkan, --device choisi à la main : pas de
// total VRAM fiable, donc pas d'agrandissement.
// Mémoire unifiée : Apple Silicon seulement. Un Mac Intel à carte AMD a une
// VRAM à lui, que Loki ne sait pas mesurer (loadModeRisk la tient pour
// inconnue, jamais pour nulle).
si.UnifiedMem = runtime.GOOS == "darwin" && runtime.GOARCH == "arm64"
// macOS, AMD, Vulkan, --device choisi à la main : pas de total VRAM fiable,
// donc pas d'agrandissement.
if runtime.GOOS == "darwin" || hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" {
return
}
@@ -450,30 +454,29 @@ func loopbackHost(h string) bool {
// restore, qui écrivent des Gio sur le disque, à quiconque joint le moteur. On
// ne le pose donc que si le moteur est fermé aux autres (boucle locale, ou clé
// d'API exigée), que le dossier existe (le moteur refuse de démarrer sinon), et
// que l'isolation servira : cache actif, CACHE_ISOLATE pas à off. Les proxys de
// Loki refusent de leur côté tout /slots qui n'est pas une lecture.
// qu'il servira : à l'isolation (cache actif, CACHE_ISOLATE pas à off), ou à
// SLOT_PERSIST accepté (backend_serve_persist.go). Les proxys de Loki refusent
// de leur côté tout /slots qui n'est pas une lecture.
func slotSaveArgs(cfg map[string]string, extra []string, si serveSysInfo) []string {
if si.SlotDir == "" || !strings.Contains(si.Help, "--slot-save-path") ||
hasAnyFlag(extra, "--slot-save-path") || !cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv) {
if si.SlotDir == "" || !strings.Contains(si.Help, "--slot-save-path") || hasAnyFlag(extra, "--slot-save-path") {
return nil
}
host := flagValue(extra, "--host")
if host == "" {
host = cfg["HOST"]
persist, _ := slotPersistPlan(cfg, extra, si)
if !persist && (!cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv)) {
return nil
}
if host == "" {
host = "0.0.0.0"
}
if !loopbackHost(host) && si.APIKey == "" {
if !slotSaveSafe(cfg, extra, si) {
return nil
}
return []string{"--slot-save-path", si.SlotDir}
}
// prepareSlotDir crée LOKI_HOME/slots (0700) et le vide : Loki n'y sauve jamais
// rien, un fichier présent ne peut venir que d'un appel qui n'aurait pas dû
// passer. Vide = dossier indisponible, le drapeau ne sera pas posé.
func prepareSlotDir(home string) string {
// prepareSlotDir crée LOKI_HOME/slots (0700) et le vide : un fichier présent ne
// peut venir que d'un appel qui n'aurait pas dû passer. Seule exception, avec
// keepPersist (SLOT_PERSIST demandé) : les états que Loki y a lui-même gardés
// à la bascule de preset, au nom reconnaissable. Vide = dossier indisponible,
// le drapeau ne sera pas posé.
func prepareSlotDir(home string, keepPersist bool) string {
dir := filepath.Join(home, "slots")
if err := os.MkdirAll(dir, 0o700); err != nil {
return ""
@@ -483,7 +486,7 @@ func prepareSlotDir(home string) string {
}
if entries, err := os.ReadDir(dir); err == nil {
for _, e := range entries {
if e.Type().IsRegular() {
if e.Type().IsRegular() && !(keepPersist && slotPersistFileRe.MatchString(e.Name())) {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
+3 -3
View File
@@ -238,7 +238,7 @@ func TestBuildServeArgsCacheRAM(t *testing.T) {
func TestPrepareSlotDir(t *testing.T) {
home := t.TempDir()
dir := prepareSlotDir(home)
dir := prepareSlotDir(home, false)
if dir != filepath.Join(home, "slots") {
t.Fatalf("dossier = %q", dir)
}
@@ -246,7 +246,7 @@ func TestPrepareSlotDir(t *testing.T) {
if err := os.WriteFile(stray, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if prepareSlotDir(home) == "" {
if prepareSlotDir(home, false) == "" {
t.Fatal("second appel")
}
if _, err := os.Stat(stray); !os.IsNotExist(err) {
@@ -258,7 +258,7 @@ func TestPrepareSlotDir(t *testing.T) {
if err := os.WriteFile(filepath.Join(bad, "slots"), nil, 0o600); err != nil {
t.Fatal(err)
}
if got := prepareSlotDir(bad); got != "" {
if got := prepareSlotDir(bad, false); got != "" {
t.Fatalf("slots est un fichier : %q", got)
}
}
+13 -6
View File
@@ -289,12 +289,19 @@ func ckptArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
//
// Le compromis, qui explique pourquoi Loki ne choisit pas : depuis b10763 le
// moteur active preserve_reasoning par défaut, et le gabarit garde alors la
// réflexion de TOUS les tours passés. Mais Loki ne renvoie pas le raisonnement
// des tours passés : un gabarit qui ne conserve rien de lui-même (Qwen3.6) rend
// alors des blocs de réflexion vides, là où « off » rend l'historique des
// anciens moteurs. À l'inverse, un gabarit qui conserve déjà de lui-même
// (Qwen3.8) changerait de rendu avec « off ». Aucun choix global n'est neutre
// pour tous les modèles : vide = défaut du moteur, inchangé.
// réflexion de TOUS les tours passés. Mais sans REASONING_ECHO, Loki ne renvoie
// pas le raisonnement des tours passés : un gabarit qui ne conserve rien de
// lui-même (Qwen3.6) rend alors des blocs de réflexion vides, là où « off » rend
// l'historique des anciens moteurs. À l'inverse, un gabarit qui conserve déjà de
// lui-même (Qwen3.8) changerait de rendu avec « off ». Aucun choix global n'est
// neutre pour tous les modèles : vide = défaut du moteur, inchangé.
//
// Avec REASONING_ECHO=on (llm_reasoning_echo.go), les deux clés se complètent
// sans se recouvrir : l'écho fournit le raisonnement, cette clé décide si le
// gabarit le garde au-delà du tour en cours. « on » donne alors un préfixe
// stable d'un message utilisateur à l'autre, au prix de plus de contexte ; le
// comptage de Loki suit le verdict de la sonde de gabarit (preservesHistory),
// pas cette clé, car un gabarit sans le réglage (Qwen3.5) l'ignore.
func reasoningPreserveArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
v := strings.ToLower(strings.TrimSpace(cfg["REASONING_PRESERVE"]))
if v == "" {
+11 -1
View File
@@ -23,7 +23,7 @@ import (
// ils décident. LLAMA_ARG_NO_CONTEXT_SHIFT est celle des moteurs anciens.
var fidelityArgEnv = []string{"LLAMA_ARG_CACHE_TYPE_K", "LLAMA_ARG_CACHE_TYPE_V",
"LLAMA_ARG_CONTEXT_SHIFT", "LLAMA_ARG_NO_CONTEXT_SHIFT", "LLAMA_ARG_CACHE_REUSE",
"LLAMA_ARG_MMPROJ", "LLAMA_ARG_MMPROJ_URL"}
"LLAMA_ARG_MMPROJ", "LLAMA_ARG_MMPROJ_URL", "LLAMA_ARG_SPEC_SYNTH_LEN"}
// probeFidelityEnv complète ArgEnv avec fidelityArgEnv. Appelée après
// probeServeGPUs, qui crée ArgEnv.
@@ -168,6 +168,16 @@ func lossyCacheNotes(extra []string, si serveSysInfo) []string {
"de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul "+
"complet%s.", n, src, ignored))
}
// Acceptation SYNTHÉTIQUE (« benchmarking only ») : le moteur garde des
// jetons du brouillon au hasard, sans les comparer à ce qu'aurait tiré le
// modèle. Loki ne les pose jamais ; écrits à la main, on le crie.
if f := hasFlagPrefix(extra, "--spec-synth-"); f != "" || si.ArgEnv["LLAMA_ARG_SPEC_SYNTH_LEN"] != "" {
if f == "" {
f = "LLAMA_ARG_SPEC_SYNTH_LEN"
}
notes = append(notes, "avertissement : "+f+" — le moteur accepte des jetons du brouillon AU HASARD, sans "+
"vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le.")
}
return notes
}
+516
View File
@@ -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"
}
+285
View File
@@ -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)
}
}
+267
View File
@@ -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()))
}
}
}
+211
View File
@@ -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")
}
+147
View File
@@ -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)
}
}
}
+238 -48
View File
@@ -22,6 +22,8 @@ import (
// MODEL_DRAFT=<nom> → la tête ou le brouillon, résolu comme MMPROJ
// SPEC_N_MAX=<n> → --spec-draft-n-max (défaut du moteur : 3)
// SPEC_SAMPLING=probabilistic → seulement avec SPEC=mtp ; greedy sinon
// SPEC=ngram → n-grammes tirés du contexte (ngram-mod), sans brouillon
// SPEC=mtp+ngram → les deux ; un n-gramme trouvé passe avant la tête MTP
//
// Rien n'y touche au modèle : chaque jeton émis est tiré par l'échantillonneur
// du modèle cible, un jeton du brouillon n'est gardé que s'il coïncide
@@ -30,6 +32,11 @@ import (
// empêcher le moteur de démarrer. D'où l'opt-in, les garde-fous d'auto et le
// jeton de tentative (specAutoVerdict) : un lancement automatique qui n'a
// jamais répondu ne se retente pas avec la même configuration.
//
// Une vérification par lot n'emprunte pas les mêmes noyaux qu'un décodage jeton
// par jeton : même distribution, mais pas le même texte au bit près — deux
// candidats presque à égalité peuvent s'inverser à graine égale, comme entre
// prefill et décodage. On compare donc des sessions rejouées, pas des diffs.
const (
// specAutoMinBuild : premier build officiel où Loki ose l'auto (MTP serveur,
@@ -37,6 +44,19 @@ const (
// peut connaître le drapeau sans le tenir.
specAutoMinBuild = 11009
specNMaxLimit = 64
// Bornes de ngram-mod, toujours écrites en clair : --spec-default pose les
// mêmes aujourd'hui, mais son code porte un TODO qui annonce d'autres types —
// une mise à jour du moteur changerait le réglage sans un mot. Un brouillon
// part dès 24 jetons qui se répètent mot pour mot, et fait 48 à 64 jetons :
// ce que recopie une boucle d'outils (chemins, diffs, arguments JSON).
specNgramNMatch = 24
specNgramNMin = 48
specNgramNMax = 64
// ggmlOffloadMinBatchDefault : seuil par défaut du moteur (ggml-cuda.cu) à
// partir duquel un lot recopie vers le GPU les poids restés en RAM.
ggmlOffloadMinBatchDefault = 32
)
// specArgEnv : les variables qui règlent la spéculation sans drapeau. Posées,
@@ -45,10 +65,28 @@ var specArgEnv = []string{"LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL", "
"LLAMA_ARG_SPEC_DRAFT_N_MAX", "LLAMA_ARG_SPEC_DRAFT_SAMPLING"}
// specUserFlags : la spéculation réglée à la main. Les ajouter EN PLUS ferait
// cumuler les types (--spec-type s'additionne) ou charger deux brouillons.
var specUserFlags = []string{"--spec-type", "-md", "--model-draft", "--spec-draft-model",
// cumuler les types (--spec-type s'additionne, sans l'avertissement « specified
// multiple times » des autres drapeaux) ou charger deux brouillons.
var specUserFlags = []string{"--spec-type", "--spec_type", "-md", "--model-draft", "--spec-draft-model",
"-hfd", "-hfrd", "--spec-draft-hf", "--hf-repo-draft", "--spec-default"}
// specUserPrefixes : des réglages de n-grammes à la main (--spec-ngram-mod-n-max,
// --spec-ngram-simple-size-n…) disent la même chose qu'un --spec-type.
var specUserPrefixes = []string{"--spec-ngram-", "--spec_ngram_"}
// hasFlagPrefix : un drapeau d'args commence-t-il par l'un de ces préfixes ?
func hasFlagPrefix(args []string, prefixes ...string) string {
for _, a := range args {
for _, p := range prefixes {
if strings.HasPrefix(a, p) {
name, _, _ := strings.Cut(a, "=")
return name
}
}
}
return ""
}
// probeSpec : variables du moteur, puis MODEL_DRAFT résolu comme MMPROJ (nom
// simple cherché dans les dossiers déclarés, ou chemin absolu). Introuvable ne
// bloque PAS le lancement, contrairement au projecteur : le brouillon n'est
@@ -91,20 +129,23 @@ func specSidecarArch(arch string) bool {
return false
}
// specMode lit SPEC : "off", "auto", "mtp", ou "" si illisible.
// specMode lit SPEC : "off", "auto", "mtp", "ngram", "mtp+ngram", ou "" si
// illisible.
func specMode(cfg map[string]string) string {
switch v := strings.ToLower(strings.TrimSpace(cfg["SPEC"])); v {
case "", "off", "non", "no", "0":
return "off"
case "auto", "mtp":
case "auto", "mtp", "ngram", "mtp+ngram":
return v
case "ngram+mtp":
return "mtp+ngram"
}
return ""
}
// specUserSet : EXTRA_ARGS ou l'environnement règlent déjà la spéculation.
func specUserSet(extra []string, argEnv map[string]string) bool {
if hasAnyFlag(extra, specUserFlags...) {
if hasAnyFlag(extra, specUserFlags...) || hasFlagPrefix(extra, specUserPrefixes...) != "" {
return true
}
for _, k := range []string{"LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL", "LLAMA_ARG_SPEC_DRAFT_HF_REPO"} {
@@ -204,7 +245,7 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
switch mode {
case "":
return nil, []string{"SPEC=" + cfg["SPEC"] + " illisible (off, auto ou mtp) : sans décodage spéculatif"}, false
return nil, []string{"SPEC=" + cfg["SPEC"] + " illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif"}, false
case "off":
if draftKey != "" {
return nil, []string{"MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)"}, false
@@ -218,49 +259,40 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
return nil, []string{"SPEC=" + mode + " : " + why + " — sans décodage spéculatif"}, false
}
// La source : MODEL_DRAFT s'il est posé, sinon la tête MTP du modèle. Dans
// les deux cas, on regarde les TENSEURS, comme llama.cpp : une clé
// nextn_predict_layers seule ne prouve pas que la tête est dans le fichier.
mtp := false
switch {
case draftKey != "":
switch {
case si.Draft == "":
why := "MODEL_DRAFT=" + draftKey + " introuvable"
if si.DraftErr != "" {
why += " (" + si.DraftErr + ")"
}
// N-grammes : leurs garde-fous d'abord, ils valent pour les deux formes.
if mode == "ngram" || mode == "mtp+ngram" {
if why := ngramBlocker(cfg, extra, si, mode); why != "" {
return skip(why)
case si.DraftGGUF == nil:
return skip("MODEL_DRAFT illisible (GGUF incomplet ou en cours de téléchargement ?)")
case si.DraftGGUF.HasNextNTensor:
if !helpSupportsMTP(si.Help) {
return skip("ce moteur ne connaît pas draft-mtp")
}
// Type explicite : llama.cpp ne le devine que sur la première tranche.
args, mtp = []string{"-md", si.Draft, "--spec-type", "draft-mtp"}, true
case specSidecarArch(si.DraftGGUF.Arch):
return skip("MODEL_DRAFT est une tête " + si.DraftGGUF.Arch +
", pas un modèle brouillon : règle -md et --spec-type dans EXTRA_ARGS")
default:
if !strings.Contains(si.Help, "--spec-type") || !strings.Contains(si.Help, "draft-simple") {
return skip("ce moteur ne connaît pas --spec-type draft-simple")
}
// Sans type, un brouillon qui n'est pas une tête MTP serait chargé en
// VRAM puis jamais utilisé.
args = []string{"-md", si.Draft, "--spec-type", "draft-simple"}
}
case si.GGUF == nil:
return skip("métadonnées du modèle illisibles")
case !si.GGUF.HasNextNTensor:
if mode == "mtp" || si.GGUF.NextN > 0 {
return skip("pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)")
}
if mode == "ngram" {
args, notes = ngramSpecArgs(cfg, extra, si, "")
return args, notes, false
}
src, mtp, why, quiet := specSource(mode, draftKey, si)
if mode == "mtp+ngram" && !mtp {
// Pas de tête MTP utilisable : la passer quand même ferait mourir le
// moteur sur « failed to create MTP context », et boucler. Les n-grammes,
// eux, n'ont besoin de rien.
if why == "" {
why = "MODEL_DRAFT n'est pas une tête MTP"
}
return nil, nil, false // auto sur un modèle sans MTP : rien à dire
case !helpSupportsMTP(si.Help):
return skip("ce moteur ne connaît pas draft-mtp")
default:
args, mtp = []string{"--spec-type", "draft-mtp"}, true
args, notes = ngramSpecArgs(cfg, extra, si, "SPEC=mtp+ngram : sans MTP ("+why+") → n-grammes seuls. ")
return args, notes, false
}
if why != "" {
return skip(why)
}
if quiet {
return nil, nil, false
}
args = src
if mode == "mtp+ngram" {
// Une seule liste : --spec-type s'additionne, mais une valeur unique se
// lit d'un coup d'œil dans le journal.
args[len(args)-1] = "draft-mtp,ngram-mod"
args = append(args, ngramModParams()...)
}
label := "brouillon " + baseName(si.Draft)
@@ -275,7 +307,10 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
notes = append(notes, "SPEC=auto → "+label+" : sortie inchangée (chaque jeton est vérifié par le modèle), "+
"~1-2 Go de VRAM en plus. Mesure le prefill ; SPEC=off pour couper.")
} else {
note := "SPEC=mtp → " + label + " imposé : sortie inchangée, ~1-2 Go de VRAM en plus"
note := "SPEC=" + mode + " → " + label + " imposé : sortie inchangée, ~1-2 Go de VRAM en plus"
if mode == "mtp+ngram" {
note += " ; n-grammes en plus (ngram-mod, prioritaires quand ils trouvent une répétition)"
}
if why := fitBlocker(cfg, extra, si); why != "" || hasAnyFlag(extra, "-dev", "--device") {
if why == "" {
why = "--device"
@@ -290,6 +325,9 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
note += " ; MTP qwen4exp très récent"
}
notes = append(notes, note)
if mode == "mtp+ngram" {
notes = append(notes, ngramCostNotes(cfg, extra, si)...)
}
}
// Nombre de jetons anticipés : défaut du moteur (3) si la clé est vide.
@@ -314,7 +352,7 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
case "", "greedy":
case "probabilistic":
switch {
case mode != "mtp":
case mode == "auto":
notes = append(notes, "SPEC_SAMPLING=probabilistic ignoré avec SPEC=auto (SPEC=mtp pour le choisir) : greedy")
case statefulSampler(extra) != "":
notes = append(notes, "SPEC_SAMPLING=probabilistic refusé avec "+statefulSampler(extra)+
@@ -334,6 +372,158 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
return args, notes, auto
}
// specSource : d'où vient le brouillon de SPEC=auto|mtp|mtp+ngram. MODEL_DRAFT
// s'il est posé, sinon la tête MTP du modèle ; dans les deux cas on regarde les
// TENSEURS, comme llama.cpp : une clé nextn_predict_layers seule ne prouve pas
// que la tête est dans le fichier. why non vide = aucun brouillon ; quiet =
// rien à dire (auto sur un modèle sans MTP).
func specSource(mode, draftKey string, si serveSysInfo) (args []string, mtp bool, why string, quiet bool) {
switch {
case draftKey != "":
switch {
case si.Draft == "":
why := "MODEL_DRAFT=" + draftKey + " introuvable"
if si.DraftErr != "" {
why += " (" + si.DraftErr + ")"
}
return nil, false, why, false
case si.DraftGGUF == nil:
return nil, false, "MODEL_DRAFT illisible (GGUF incomplet ou en cours de téléchargement ?)", false
case si.DraftGGUF.HasNextNTensor:
if !helpSupportsMTP(si.Help) {
return nil, false, "ce moteur ne connaît pas draft-mtp", false
}
// Type explicite : llama.cpp ne le devine que sur la première tranche.
return []string{"-md", si.Draft, "--spec-type", "draft-mtp"}, true, "", false
case specSidecarArch(si.DraftGGUF.Arch):
return nil, false, "MODEL_DRAFT est une tête " + si.DraftGGUF.Arch +
", pas un modèle brouillon : règle -md et --spec-type dans EXTRA_ARGS", false
case mode == "mtp+ngram":
return nil, false, "", false // un modèle brouillon n'est pas une tête MTP
default:
if !strings.Contains(si.Help, "--spec-type") || !strings.Contains(si.Help, "draft-simple") {
return nil, false, "ce moteur ne connaît pas --spec-type draft-simple", false
}
// Sans type, un brouillon qui n'est pas une tête MTP serait chargé en
// VRAM puis jamais utilisé.
return []string{"-md", si.Draft, "--spec-type", "draft-simple"}, false, "", false
}
case si.GGUF == nil:
return nil, false, "métadonnées du modèle illisibles", false
case !si.GGUF.HasNextNTensor:
if mode != "auto" || si.GGUF.NextN > 0 {
return nil, false, "pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)", false
}
return nil, false, "", true
case !helpSupportsMTP(si.Help):
return nil, false, "ce moteur ne connaît pas draft-mtp", false
}
return []string{"--spec-type", "draft-mtp"}, true, "", false
}
// ngramModParams : les trois bornes de ngram-mod, toujours explicites.
func ngramModParams() []string {
return []string{
"--spec-ngram-mod-n-match", strconv.Itoa(specNgramNMatch),
"--spec-ngram-mod-n-min", strconv.Itoa(specNgramNMin),
"--spec-ngram-mod-n-max", strconv.Itoa(specNgramNMax),
}
}
// ngramBlocker dit pourquoi SPEC=ngram|mtp+ngram ne s'active pas, ou vide.
//
// - Le moteur doit connaître --spec-ngram-mod-n-match, pas seulement le mot
// « ngram-mod » : les moteurs d'avant le renommage ont encore le type dans
// leur liste mais refusent les drapeaux actuels, et meurent au démarrage.
// - SPEC=ngram n'a pas de brouillon : un --spec-draft-* d'EXTRA_ARGS veut dire
// qu'on règle un brouillon à la main, on ne mélange pas.
// - Poids en RAM (experts MoE sur CPU…) : llama.cpp recopie vers le GPU les
// poids d'un lot dès GGML_OP_OFFLOAD_MIN_BATCH jetons (32 par défaut). Un
// lot de vérification de n_max+1 jetons au-dessus du seuil ferait passer
// les experts de chaque couche par le PCIe — voire par le disque si le
// modèle mappé dépasse la RAM — à chaque brouillon. Refusé tant que le seuil
// n'est pas relevé au-dessus du lot.
func ngramBlocker(cfg map[string]string, extra []string, si serveSysInfo, mode string) string {
if !strings.Contains(si.Help, "--spec-ngram-mod-n-match") {
return "ce moteur ne connaît pas --spec-ngram-mod-n-match"
}
if mode == "ngram" {
if f := hasFlagPrefix(extra, "--spec-draft-", "--spec_draft_"); f != "" {
return f + " dans EXTRA_ARGS (brouillon réglé à la main)"
}
}
blocks := 0
if si.GGUF != nil {
blocks = si.GGUF.BlockCount
}
if r := cpuWeights(cfg, extra, si.ArgEnv, blocks); r != "" {
if thr := opOffloadThreshold(cfg, si); specNgramNMax+1 >= thr {
return fmt.Sprintf("%s : un lot de vérification de %d jetons atteint GGML_OP_OFFLOAD_MIN_BATCH (%d) et "+
"recopierait ces poids vers le GPU à chaque brouillon — OP_OFFLOAD_MIN_BATCH=128 pour l'essayer, à mesurer",
r, specNgramNMax+1, thr)
}
}
return ""
}
// opOffloadThreshold : le seuil GGML_OP_OFFLOAD_MIN_BATCH que verra le moteur —
// celui de l'environnement, sinon celui d'OP_OFFLOAD_MIN_BATCH, sinon le défaut.
func opOffloadThreshold(cfg map[string]string, si serveSysInfo) int {
v := strings.TrimSpace(si.UserEnv["GGML_OP_OFFLOAD_MIN_BATCH"])
if v == "" {
v, _ = opOffloadMinBatchEnv(cfg, si)
}
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
return ggmlOffloadMinBatchDefault
}
// ngramSpecArgs : SPEC=ngram, ou le repli de mtp+ngram sans tête MTP (lead dit
// pourquoi). Pas de --spec-draft-* : il n'y a pas de brouillon.
func ngramSpecArgs(cfg map[string]string, extra []string, si serveSysInfo, lead string) (args, notes []string) {
args = append([]string{"--spec-type", "ngram-mod"}, ngramModParams()...)
notes = append(notes, lead+fmt.Sprintf("SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de %d à %d jetons) : "+
"distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer "+
"(acceptation par nature dans /api/perf/summary)", specNgramNMin, specNgramNMax))
if lead == "" {
// --spec-type explicite : llama.cpp ne choisit plus de lui-même la tête MTP.
switch {
case strings.TrimSpace(cfg["MODEL_DRAFT"]) != "":
notes = append(notes, "MODEL_DRAFT ignoré avec SPEC=ngram : SPEC=mtp+ngram pour les deux")
case si.GGUF != nil && si.GGUF.HasNextNTensor:
notes = append(notes, "tête MTP du modèle inutilisée avec SPEC=ngram : SPEC=mtp+ngram pour les deux")
}
}
if strings.TrimSpace(cfg["SPEC_N_MAX"]) != "" || strings.TrimSpace(cfg["SPEC_SAMPLING"]) != "" {
notes = append(notes, "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler")
}
return args, append(notes, ngramCostNotes(cfg, extra, si)...)
}
// ngramCostNotes : ce que les longs brouillons coûtent, là où ça se voit.
func ngramCostNotes(cfg map[string]string, extra []string, si serveSysInfo) []string {
var notes []string
if si.GGUF != nil && si.GGUF.Hybrid {
notes = append(notes, "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM "+
"hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit")
}
blocks := 0
if si.GGUF != nil {
blocks = si.GGUF.BlockCount
}
if cpuWeights(cfg, extra, si.ArgEnv, blocks) != "" && si.ModelBytes > 0 && si.RAMMiB > 0 &&
si.ModelBytes > si.RAMMiB<<20 {
notes = append(notes, "modèle plus gros que la RAM, poids sur CPU : un lot de vérification lit la plupart des "+
"experts de chaque couche — surveille les lectures disque, pas seulement les jetons/s")
}
if nglForced(cfg, extra) {
notes = append(notes, fmt.Sprintf("NGL imposé (--fit inactif) : les logits de %d positions par slot prennent "+
"quelques dizaines de Mio de VRAM en plus", specNgramNMax+1))
}
return notes
}
// --- Jeton de tentative ------------------------------------------------------
//
// Un essai automatique qui fait tomber le moteur ne doit pas se rejouer : sous
+161
View File
@@ -332,3 +332,164 @@ func TestLastOffload(t *testing.T) {
t.Errorf("tout sur GPU : got %d/%d", n, m)
}
}
// helpNgram : un moteur qui connaît les drapeaux actuels de ngram-mod.
const helpNgram = helpSpec + `--spec-ngram-mod-n-min N minimum number of ngram tokens (default: 48)
--spec-ngram-mod-n-max N maximum number of ngram tokens (default: 64)
--spec-ngram-mod-n-match N ngram-mod lookup length (default: 24)
`
func TestSpecArgsNgram(t *testing.T) {
ngram := []string{"--spec-type", "ngram-mod",
"--spec-ngram-mod-n-match", "24", "--spec-ngram-mod-n-min", "48", "--spec-ngram-mod-n-max", "64"}
both := []string{"--spec-type", "draft-mtp,ngram-mod",
"--spec-ngram-mod-n-match", "24", "--spec-ngram-mod-n-min", "48", "--spec-ngram-mod-n-max", "64",
"--spec-draft-sampling", "greedy"}
cfgOf := func(kv ...string) map[string]string {
m := map[string]string{}
for i := 0; i+1 < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
}
dense := func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "llama", BlockCount: 32} }
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
notes int
}{
{name: "ngram, modèle dense : forme explicite, jamais --spec-default",
cfg: cfgOf("SPEC", "ngram"), si: dense, want: ngram, notes: 1},
{name: "ngram+mtp s'écrit aussi dans l'autre sens",
cfg: cfgOf("SPEC", "ngram+mtp"), want: both, notes: 2},
{name: "ngram sur un modèle hybride à tête MTP : tête inutilisée et points de reprise dits",
cfg: cfgOf("SPEC", "ngram"), want: ngram, notes: 3},
{name: "ngram, moteur qui n'a que le type dans sa liste : rien",
cfg: cfgOf("SPEC", "ngram"), notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpec }},
{name: "mtp+ngram, moteur sans drapeaux ngram-mod : rien",
cfg: cfgOf("SPEC", "mtp+ngram"), notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpec }},
{name: "mtp+ngram avec la tête dans le fichier : une seule liste de types",
cfg: cfgOf("SPEC", "mtp+ngram"), want: both, notes: 2},
{name: "mtp+ngram sans tête MTP : n-grammes seuls, jamais « failed to create MTP context »",
cfg: cfgOf("SPEC", "mtp+ngram"), si: dense, want: ngram, notes: 1},
{name: "mtp+ngram, moteur sans draft-mtp : n-grammes seuls",
cfg: cfgOf("SPEC", "mtp+ngram"), want: ngram, notes: 2,
si: func(s *serveSysInfo) {
s.Help = strings.ReplaceAll(s.Help, "draft-mtp", "draft-xxx")
}},
{name: "mtp+ngram avec un petit modèle brouillon : n-grammes seuls, pas de -md",
cfg: cfgOf("SPEC", "mtp+ngram", "MODEL_DRAFT", "Qwen3-0.6B.gguf"), want: ngram, notes: 2,
si: func(s *serveSysInfo) {
s.Draft = "/models/Qwen3-0.6B.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "qwen3", BlockCount: 28}
}},
{name: "mtp+ngram avec la tête MTP publiée à part",
cfg: cfgOf("SPEC", "mtp+ngram", "MODEL_DRAFT", "mtp-q.gguf"), notes: 2,
want: append([]string{"-md", "/models/mtp-q.gguf"}, both...),
si: func(s *serveSysInfo) {
s.GGUF.HasNextNTensor = false
s.Draft = "/models/mtp-q.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
}},
{name: "ngram + MODEL_DRAFT : brouillon ignoré, et dit",
cfg: cfgOf("SPEC", "ngram", "MODEL_DRAFT", "mtp-q.gguf"), si: dense, want: ngram, notes: 2},
{name: "ngram + SPEC_N_MAX : ignoré, et dit",
cfg: cfgOf("SPEC", "ngram", "SPEC_N_MAX", "4"), si: dense, want: ngram, notes: 2},
{name: "--spec-type dans EXTRA_ARGS : Loki se tait",
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--spec-type", "ngram-simple"}, notes: 1},
{name: "--spec_type=… dans EXTRA_ARGS : Loki se tait",
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--spec_type=ngram-mod"}, notes: 1},
{name: "--spec-default dans EXTRA_ARGS : Loki se tait",
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"--spec-default"}, notes: 1},
{name: "--spec-ngram-mod-n-max dans EXTRA_ARGS : Loki se tait",
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--spec-ngram-mod-n-max=16"}, notes: 1},
{name: "-md dans EXTRA_ARGS : Loki se tait",
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"-md", "d.gguf"}, notes: 1},
{name: "LLAMA_ARG_SPEC_TYPE posée : Loki se tait", cfg: cfgOf("SPEC", "ngram"), notes: 1,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SPEC_TYPE"] = "ngram-mod" }},
{name: "ngram + --spec-draft-n-max à la main : rien",
cfg: cfgOf("SPEC", "ngram"), si: dense, extra: []string{"--spec-draft-n-max", "8"}, notes: 1},
{name: "mtp+ngram + --spec-draft-n-max à la main : la règle de SPEC=mtp",
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"--spec-draft-n-max", "8"}, want: both, notes: 2},
{name: "experts sur CPU, seuil par défaut : refusé, OP_OFFLOAD_MIN_BATCH suggéré",
cfg: cfgOf("SPEC", "ngram"), si: dense, extra: []string{"--n-cpu-moe", "40"}, notes: 1},
{name: "experts sur CPU, mtp+ngram : refusé aussi",
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"-ot", "exps=CPU"}, notes: 1},
{name: "experts sur CPU, OP_OFFLOAD_MIN_BATCH=64 : encore sous le lot de 65, refusé",
cfg: cfgOf("SPEC", "ngram", "OP_OFFLOAD_MIN_BATCH", "64"), si: dense, extra: []string{"-cmoe"}, notes: 1},
{name: "experts sur CPU, OP_OFFLOAD_MIN_BATCH=128 : accepté",
cfg: cfgOf("SPEC", "ngram", "OP_OFFLOAD_MIN_BATCH", "128"), si: dense, extra: []string{"-cmoe"}, want: ngram, notes: 1},
{name: "experts sur CPU, GGML_OP_OFFLOAD_MIN_BATCH=256 dans l'environnement : accepté",
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--n-cpu-moe", "40"}, want: ngram, notes: 1,
si: func(s *serveSysInfo) {
dense(s)
s.UserEnv = map[string]string{"GGML_OP_OFFLOAD_MIN_BATCH": "256"}
}},
{name: "experts sur CPU, modèle plus gros que la RAM : accepté avec l'avertissement",
cfg: cfgOf("SPEC", "ngram", "OP_OFFLOAD_MIN_BATCH", "128"), extra: []string{"--n-cpu-moe", "40"},
want: ngram, notes: 2,
si: func(s *serveSysInfo) {
dense(s)
s.ModelBytes, s.RAMMiB = 82<<30, 64<<10
}},
{name: "NGL imposé : logits en VRAM dits",
cfg: cfgOf("SPEC", "ngram", "NGL", "40"), si: dense, want: ngram, notes: 2},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := mtpReady()
si.Help = helpNgram
if c.si != nil {
c.si(&si)
}
got, notes, isAuto := specArgs(c.cfg, c.extra, si)
if !reflect.DeepEqual(got, c.want) {
t.Errorf("args = %q, attendu %q", got, c.want)
}
if isAuto {
t.Error("ngram n'est jamais automatique")
}
if len(notes) != c.notes {
t.Errorf("%d notes, attendu %d : %q", len(notes), c.notes, notes)
}
for _, a := range got {
if a == "--spec-default" || strings.HasPrefix(a, "--spec-synth") {
t.Errorf("drapeau interdit %q", a)
}
}
})
}
}
// Sans SPEC, la même ligne qu'avant, quel que soit le moteur.
func TestBuildServeArgsNgramDefault(t *testing.T) {
si := mtpReady()
si.Help = helpNgram
base, _, _ := buildServeArgs(map[string]string{}, nil, "/bin/llama-server", si)
for _, a := range base {
if strings.HasPrefix(a, "--spec") {
t.Fatalf("SPEC absent : aucun drapeau de spéculation, %q", base)
}
}
got, _, _ := buildServeArgs(map[string]string{"SPEC": "ngram"}, []string{"--jinja"}, "/bin/llama-server", si)
i := slices.Index(got, "--spec-type")
if i < 0 || got[i+1] != "ngram-mod" || slices.Index(got, "--jinja") < i {
t.Fatalf("SPEC=ngram : %q", got)
}
}
func TestSynthAcceptanceWarned(t *testing.T) {
for _, extra := range [][]string{{"--spec-synth-len", "3"}, {"--spec-synth-rates=0.5,0.5"}} {
if n := lossyCacheNotes(extra, serveSysInfo{ArgEnv: map[string]string{}}); len(n) != 1 ||
!strings.Contains(n[0], "AU HASARD") {
t.Errorf("%q : %q", extra, n)
}
}
si := serveSysInfo{ArgEnv: map[string]string{"LLAMA_ARG_SPEC_SYNTH_LEN": "3"}}
if n := lossyCacheNotes(nil, si); len(n) != 1 {
t.Errorf("variable : %q", n)
}
}
+359
View File
@@ -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
}
+214
View File
@@ -0,0 +1,214 @@
package loki
import (
"reflect"
"slices"
"strings"
"testing"
)
const helpTensor = helpRecent + `-sm, --split-mode {none,layer,row,tensor}
how to split the model across multiple GPUs
-ts, --tensor-split N0,N1,N2,... fraction of the model to offload to each GPU
`
// tensorReady : un hybride 64 couches de 16 Gio sur 16 + 12 Gio de VRAM, sur
// un moteur qui propose -sm tensor. Le cas où SPLIT_MODE=tensor est accepté.
func tensorReady() serveSysInfo {
return serveSysInfo{
Help: helpTensor,
Model: "/models/Qwen3.6-27B.gguf",
GGUF: &GGUFInfo{Arch: "qwen35", BlockCount: 64, KeyLen: 256, ValLen: 256, HeadCountKV: 4,
Hybrid: true, FullAttnInterval: 4},
ModelBytes: 16 << 30,
ArgEnv: map[string]string{},
UserEnv: map[string]string{},
SplitDevs: []splitDev{{ID: "CUDA0", TotalMiB: 16311}, {ID: "CUDA1", TotalMiB: 12288}},
}
}
func TestSplitTensorPlan(t *testing.T) {
tensor := map[string]string{"SPLIT_MODE": "tensor", "CTX": "65536"}
with := func(kv ...string) map[string]string {
m := map[string]string{}
for k, v := range tensor {
m[k] = v
}
for i := 0; i+1 < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
}
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
on bool
why string // fragment attendu de la raison du refus
}{
{name: "clé absente : rien, en silence", cfg: map[string]string{}},
{name: "SPLIT_MODE=layer : le défaut, rien", cfg: map[string]string{"SPLIT_MODE": "layer"}},
{name: "accepté", cfg: tensor, on: true},
{name: "illisible", cfg: with("SPLIT_MODE", "rows"), why: "illisible"},
{name: "aide qui ne cite que --tensor-split : refusé",
cfg: tensor, why: "-sm tensor", si: func(s *serveSysInfo) { s.Help = helpRecent + "-ts, --tensor-split N0,N1\n" }},
{name: "preset externe", cfg: with(extKeyFlag, "1"), why: "externe"},
{name: "-sm dans EXTRA_ARGS : l'utilisateur garde la main", cfg: tensor, extra: []string{"-sm", "layer"}, why: "-sm layer"},
{name: "LLAMA_ARG_SPLIT_MODE", cfg: tensor, why: "LLAMA_ARG_SPLIT_MODE",
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SPLIT_MODE"] = "row" }},
{name: "-fa off : refusé", cfg: tensor, extra: []string{"-fa", "off"}, why: "Flash Attention"},
{name: "-fa on : accepté", cfg: tensor, extra: []string{"-fa", "on"}, on: true},
{name: "--backend-sampling : refusé", cfg: tensor, extra: []string{"--backend-sampling"}, why: "backend-sampling"},
{name: "LLAMA_ARG_BACKEND_SAMPLING : refusé", cfg: tensor, why: "backend-sampling",
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_BACKEND_SAMPLING"] = "1" }},
{name: "KV_TYPE=q8_0 : refusé, jamais réécrit", cfg: with("KV_TYPE", "q8_0"), why: "q8_0"},
{name: "-ctk q8_0 dans EXTRA_ARGS (preset MoE) : refusé", cfg: tensor,
extra: []string{"--cache-type-k", "q8_0", "--cache-type-v", "q8_0"}, why: "q8_0"},
{name: "KV_TYPE_V=q4_0 : refusé", cfg: with("KV_TYPE_V", "q4_0"), why: "q4_0"},
{name: "KV bf16 : accepté", cfg: with("KV_TYPE", "bf16"), on: true},
{name: "--n-cpu-moe : refusé", cfg: tensor, extra: []string{"--n-cpu-moe", "40"}, why: "n-cpu-moe"},
{name: "-ot : refusé", cfg: tensor, extra: []string{"-ot", "exps=CPU"}, why: "surcharge"},
{name: "cache KV sur CPU : refusé", cfg: tensor, extra: []string{"-nkvo"}, why: "KV sur CPU"},
{name: "NGL=40 : refusé", cfg: with("NGL", "40"), why: "NGL=40"},
{name: "NGL=999 : accepté", cfg: with("NGL", "999"), on: true},
{name: "-ngl 99 dans EXTRA_ARGS (≥ toutes les couches) : accepté", cfg: tensor, extra: []string{"-ngl", "99"}, on: true},
{name: "-ngl 30 dans EXTRA_ARGS : refusé", cfg: tensor, extra: []string{"-ngl", "30"}, why: "-ngl 30"},
{name: "CTX=0 : refusé", cfg: with("CTX", "0"), why: "non chiffré"},
{name: "VRAM insuffisante : refusé, CTX jamais réduit", cfg: with("CTX", "262144"), why: "VRAM insuffisante"},
{name: "qwen4exp : refusé", cfg: tensor, why: "qwen4exp", si: func(s *serveSysInfo) { s.GGUF.Arch = "qwen4exp" }},
{name: "mamba (refusé par llama.cpp) : refusé proprement", cfg: tensor, why: "mamba",
si: func(s *serveSysInfo) { s.GGUF.Arch = "mamba" }},
{name: "GGUF illisible", cfg: tensor, why: "illisibles", si: func(s *serveSysInfo) { s.GGUF = nil }},
{name: "SPEC=mtp : refusé", cfg: with("SPEC", "mtp"), why: "spéculatif"},
{name: "SIDE_SLOT=on : refusé", cfg: with("SIDE_SLOT", "on"), why: "SIDE_SLOT"},
{name: "une seule carte", cfg: tensor, why: "une seule carte",
si: func(s *serveSysInfo) { s.SplitDevs = s.SplitDevs[:1] }},
{name: "cartes inconnues", cfg: tensor, why: "inconnues", si: func(s *serveSysInfo) { s.SplitDevs = nil }},
{name: "moteur Vulkan : refusé", cfg: tensor, why: "CUDA",
si: func(s *serveSysInfo) { s.SplitDevs = []splitDev{{"Vulkan0", 16311}, {"Vulkan1", 12288}} }},
{name: "--device d'une seule carte : refusé", cfg: tensor, extra: []string{"--device", "CUDA0"}, why: "une seule carte"},
{name: "--device inconnu : refusé", cfg: tensor, extra: []string{"-dev", "CUDA0,CUDA7"}, why: "CUDA7"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := tensorReady()
if c.si != nil {
c.si(&si)
}
on, why, _, _ := splitPlan(c.cfg, c.extra, si)
if on != c.on {
t.Fatalf("on = %v, attendu %v (%q)", on, c.on, why)
}
if !strings.Contains(why, c.why) || (c.why == "" && why != "") {
t.Errorf("raison %q, attendu %q", why, c.why)
}
})
}
}
func TestSplitTensorArgs(t *testing.T) {
const bin = "/opt/llama/llama-server"
cfg := map[string]string{"SPLIT_MODE": "tensor", "CTX": "65536", "NGL": "999"}
// Clé absente : la ligne et l'environnement d'avant, à l'octet près, même
// sur un moteur et une machine où le mode tensor serait possible.
plain := map[string]string{"CTX": "65536", "NGL": "999"}
got, env, _ := buildServeArgs(plain, nil, bin, tensorReady())
si := tensorReady()
si.Help, si.SplitDevs = helpRecent, nil
want, wantEnv, _ := buildServeArgs(plain, nil, bin, si)
if !reflect.DeepEqual(got, want) || !reflect.DeepEqual(env, wantEnv) {
t.Fatalf("sans SPLIT_MODE : %q %v, attendu %q %v", got, env, want, wantEnv)
}
got, env, notes := buildServeArgs(cfg, nil, bin, tensorReady())
tail := []string{"-ngl", "all", "-sm", "tensor", "-ts", "57,43", "-fa", "on"}
if i := slices.Index(got, "-ngl"); i < 0 || !reflect.DeepEqual(got[i:i+len(tail)], tail) {
t.Fatalf("args = %q, attendu …%q…", got, tail)
}
if env["GGML_CUDA_ALLREDUCE"] != "internal" {
t.Errorf("GGML_CUDA_ALLREDUCE = %q, attendu internal (NCCL compresse en BF16)", env["GGML_CUDA_ALLREDUCE"])
}
if len(notes) == 0 || !strings.Contains(strings.Join(notes, "\n"), "NCCL") {
t.Errorf("la note doit parler de NCCL : %q", notes)
}
// Les choix de l'utilisateur gagnent et ne sont jamais doublés.
extra := []string{"-ts", "60,40", "-fa", "on", "-ngl", "all"}
got, _, _ = buildServeArgs(cfg, extra, bin, tensorReady())
for _, f := range []string{"-ts", "-fa", "-ngl", "-sm"} {
if n := countFlag(got, f); n != 1 {
t.Errorf("%s présent %d fois : %q", f, n, got)
}
}
// --device dans l'ordre inverse : la répartition suit.
got, _, _ = buildServeArgs(cfg, []string{"--device", "CUDA1,CUDA0"}, bin, tensorReady())
if argValue(got, "-ts") != "43,57" {
t.Errorf("-ts = %q, attendu 43,57", argValue(got, "-ts"))
}
// GGML_CUDA_ALLREDUCE déjà posé : gardé, et NCCL signalé comme avec perte.
si = tensorReady()
si.UserEnv["GGML_CUDA_ALLREDUCE"] = "nccl"
_, env, notes = buildServeArgs(cfg, nil, bin, si)
if _, set := env["GGML_CUDA_ALLREDUCE"]; set {
t.Errorf("choix de l'utilisateur écrasé : %v", env)
}
if !strings.Contains(strings.Join(notes, "\n"), "BF16") {
t.Errorf("NCCL doit être signalé : %q", notes)
}
// Les réglages qui lisent le placement voient le mode tensor : pas de
// graphes Q/K/V en parallèle, pas de --fit-target, pas de file CUDA.
withOpts := map[string]string{"CUDA_GRAPH_OPT": "on", "FIT_TARGET": "1024"}
for k, v := range cfg {
withOpts[k] = v
}
got, env, _ = buildServeArgs(withOpts, nil, bin, tensorReady())
if env["GGML_CUDA_GRAPH_OPT"] != "" || slices.Contains(got, "--fit-target") {
t.Errorf("mode tensor : ni GGML_CUDA_GRAPH_OPT ni --fit-target, %q %v", got, env)
}
// Moteur sans -sm tensor : aucun compagnon (-ngl all, -ts, -fa), une note.
si = tensorReady()
si.Help = helpRecent
got, env, notes = buildServeArgs(cfg, nil, bin, si)
if slices.Contains(got, "-sm") || slices.Contains(got, "-ts") || slices.Contains(got, "-fa") ||
argValue(got, "-ngl") != "auto" || env["GGML_CUDA_ALLREDUCE"] != "" {
t.Errorf("refusé : ligne d'avant attendue, %q %v", got, env)
}
if !strings.Contains(strings.Join(notes, "\n"), "SPLIT_MODE=tensor refusé") {
t.Errorf("le refus doit se dire : %q", notes)
}
}
func countFlag(args []string, f string) int {
n := 0
for _, a := range args {
if a == f {
n++
}
}
return n
}
func TestSplitTensorSlotPersist(t *testing.T) {
si := tensorReady()
si.Help += "--slot-save-path PATH\n"
si.SlotDir = "/data/slots"
cfg := map[string]string{"SLOT_PERSIST": "on", "HOST": "127.0.0.1", "SPLIT_MODE": "tensor"}
if ok, why := slotPersistPlan(cfg, nil, si); ok || !strings.Contains(why, "SPLIT_MODE") {
t.Errorf("SLOT_PERSIST avec SPLIT_MODE : refusé attendu, got %v %q", ok, why)
}
}
func TestHelpSupportsSplitTensor(t *testing.T) {
if helpSupportsSplitTensor(helpRecent + "-ts, --tensor-split N0,N1 fraction\n--override-tensor\n") {
t.Error("« tensor » ailleurs dans l'aide ne suffit pas")
}
if !helpSupportsSplitTensor(helpTensor) {
t.Error("liste littérale de -sm : oui")
}
}
File diff suppressed because it is too large. Load diff
+392
View File
@@ -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()
}
+351
View File
@@ -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
+834
View File
@@ -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")
}
}
+5 -1
View File
@@ -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()
@@ -90,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.
@@ -155,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))
}
}
}
+214 -49
View File
@@ -72,6 +72,9 @@ func compactEnabled() bool {
// ctxWindow renvoie la fenêtre de contexte configurée (config.env CTX), 32768
// par défaut — la même valeur que celle passée à llama-server au lancement.
// Avec SIDE_SLOT, le moteur reçoit -c 2×CTX, mais chacun de ses deux slots
// fait CTX jetons : la fenêtre d'une conversation, comme d'un travail annexe,
// reste CTX (backend_serve_side.go).
func ctxWindow() int {
if v := ReadConfig()["CTX"]; v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
@@ -171,21 +174,31 @@ func msgText(m Message) string {
// par token, plus un forfait par message pour le rôle et les délimiteurs). C'est
// volontairement approximatif : le comptage EXACT vient de llama.cpp
// (PromptTokensTotal) ; ici on veut juste décider quand compacter.
// Sans le raisonnement renvoyé (REASONING_ECHO) : celui-ci dépend de la
// position du message et de la politique d'envoi, il s'ajoute par echoTokens
// (estimateTokens, compactBounds).
func msgTokens(m Message) int {
n := 4
n += len(msgText(m)) / 4
for _, tc := range m.ToolCalls {
n += (len(tc.Function.Name) + len(tc.Function.Arguments)) / 4
}
return n
// Image gardée (KEEP_TURN_IMAGES) : son coût mesuré par le moteur, que le
// seul marqueur textuel sous-estimerait de loin. 0 sans la clé.
return n + m.imgTokens
}
// estimateTokens estime la taille de l'historique en tokens.
// estimateTokens estime la taille de l'historique en tokens, raisonnement
// renvoyé compris quand il part réellement (echoTokens : rien sans
// REASONING_ECHO).
func estimateTokens(msgs []Message) int {
total := 0
for _, m := range msgs {
total += msgTokens(m)
}
for _, n := range echoTokens(msgs) {
total += n
}
return total
}
@@ -331,8 +344,14 @@ func compactBounds(msgs []Message, tailBudget int) (head, tailStart int) {
}
tailStart = len(msgs)
acc := 0
// Même compte que estimateTokens, qui fixe le budget : raisonnement renvoyé
// compris, sinon la queue avalait plus que sa part.
echo := echoTokens(msgs)
for i := len(msgs) - 1; i >= head; i-- {
acc += msgTokens(msgs[i])
if echo != nil {
acc += echo[i]
}
tailStart = i
if acc >= tailBudget {
break
@@ -352,6 +371,35 @@ func compactBounds(msgs []Message, tailBudget int) (head, tailStart int) {
}
func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, bool) {
out, changed, _ := compactMessagesOpt(ctx, msgs, caps, compactOpts{})
return out, changed
}
// compactMessagesOpt : compactMessages, avec les options de
// COMPACT_CONTINUATION (chat_compact_cont.go) — options nulles, exactement le
// chemin d'avant. Bornes, archives, demande réinjectée et sortie restent sur la
// vue MODÈLE (msgs) dans tous les cas ; la vue d'envoi ne sert qu'à construire
// la requête de résumé. refused : un résumé a été obtenu, mais la réduction
// reste sous les 20 % exigés.
//
// Images gardées par KEEP_TURN_IMAGES (chat_keep_images.go) : retirées
// D'ABORD, dans tout l'historique — leur légende et imageLostMarker restent.
// Leur retrait suffit à changer l'historique, même si le résumé qui suit est
// refusé. Sans la clé, il n'y en a aucune : chemin d'avant.
func compactMessagesOpt(ctx context.Context, msgs []Message, caps Caps, opt compactOpts) (out []Message, changed, refused bool) {
stripped, freed := dropKeptImages(msgs)
if freed == 0 {
return compactMessagesCore(ctx, msgs, caps, opt)
}
logKeptImagesDropped("compaction", freed)
if out, changed, _ = compactMessagesCore(ctx, stripped, caps, opt); changed {
return out, true, false
}
return stripped, true, false
}
// compactMessagesCore : la compaction elle-même (voir compactMessagesOpt).
func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt compactOpts) (out []Message, changed, refused bool) {
// Budget de queue = fraction de la CONVERSATION (pas de la fenêtre). Le lier à
// la fenêtre était le bug : une conversation de 25k tokens dans une fenêtre de
// 64k gardait 16k (0.25×64k) en queue → torse minuscule → réduction < 20% →
@@ -362,11 +410,20 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
// Rien à compacter : le torse [head, tailStart) est vide.
if tailStart <= head {
return msgs, false
return msgs, false, false
}
torso := msgs[head:tailStart]
// Même un résumé vide ne ferait pas passer la garantie de réduction (plus
// bas) : inutile d'appeler le modèle ni d'archiver quoi que ce soit, la
// compaction serait refusée. Borne basse exacte, jamais un refus de plus
// qu'aujourd'hui. Avec COMPACT_CONTINUATION seulement.
if opt.precheck && compactHopeless(msgs, head, tailStart) {
fmt.Fprintf(os.Stderr, "[compact] résumé non demandé : même vide, la réduction resterait sous 20 %%\n")
return msgs, false, false
}
// 3. Archivage des gros blocs (mémoire longue). AVANT de compacter, chaque bloc
// du torse assez long est enregistré VERBATIM sous un id (r7…) : le résumé et
// le repli le remplacent par une référence à cet id, que le modèle pourra
@@ -403,6 +460,10 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
pruned := make([]Message, len(torso))
for i, m := range torso {
pruned[i] = m
// Le raisonnement renvoyé (REASONING_ECHO) d'un tour compacté ne repart
// pas : comme le résumé, qui ne l'a jamais vu (renderTranscript), le torse
// revient à ce que Loki envoyait sans la clé.
pruned[i].ReasoningContent, pruned[i].ReasoningModel = "", ""
if e := archived[i]; e != nil {
pruned[i].Content = recallMarker(e.id, e.label)
continue
@@ -450,7 +511,23 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
}
}
}
summary, err := summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
// Sans les mises à jour du projet (PROJ_SNAPSHOT) : ce sont des données de
// Loki, pas la conversation, et le bloc projet est repris tout neuf après la
// compaction. Sans la clé, rien à retirer.
forSummary = stripContextUpdates(forSummary)
var summary string
var err error
if opt.view != nil {
// COMPACT_CONTINUATION : le résumé prolonge le prompt que le moteur a
// déjà en cache. Au moindre écart, repli sur la transcription d'avant.
summary, err = summarizeContinuation(ctx, msgs, tailStart, opt.view, caps.Code)
if err != nil {
fmt.Fprintf(os.Stderr, "[compact] continuation écartée (%v) : résumé sur transcription\n", err)
summary, err = summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
}
} else {
summary, err = summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
}
var mid []Message
if err != nil || summaryLooksEmpty(summary) {
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
@@ -475,7 +552,7 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
}
mid = []Message{
{Role: "user", Content: sumMsg},
{Role: "assistant", Content: "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done."},
{Role: "assistant", Content: compactAckMsg},
}
}
@@ -500,31 +577,13 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
// Un rappel de loki (budget d'outils, relance) a le rôle `user` sans être une
// demande (isLokiInjected) : ni il ne prouve qu'une demande a été servie, ni
// il ne se réinjecte à la place de la vraie.
tailHasUser := false
for _, m := range msgs[tailStart:] {
if m.Role == "user" && !isLokiInjected(m) {
tailHasUser = true
break
}
}
summarized := err == nil && !summaryLooksEmpty(summary)
var pending []Message
for i := len(torso) - 1; i >= 0 && summarized && !tailHasUser; i-- {
if torso[i].Role != "user" || isLokiInjected(torso[i]) {
continue
}
if strings.HasPrefix(msgText(torso[i]), compactSummaryPrefix) {
continue // résumé d'une compaction précédente, pas une demande
}
pending = []Message{torso[i]}
break
if summarized {
pending = compactPending(msgs, torso, tailStart)
}
out := make([]Message, 0, head+len(mid)+len(pending)+len(msgs)-tailStart)
out = append(out, msgs[:head]...)
out = append(out, mid...)
out = append(out, pending...)
out = append(out, msgs[tailStart:]...)
out = compactAssemble(msgs, head, tailStart, mid, pending)
// Garantie de réduction : on n'accepte la compaction que si elle enlève au
// moins ~20% du contexte estimé. Sinon (torse déjà maigre, résumé peu rentable)
@@ -532,9 +591,55 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
// vraiment réduire, puis re-déclenchait aussitôt.
before, after := estimateTokens(msgs), estimateTokens(out)
if after > before*4/5 {
return msgs, false
return msgs, false, summarized
}
return out, true
return out, true, false
}
// compactAckMsg : la réponse assistant synthétique qui suit le résumé.
const compactAckMsg = "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done."
// compactPending : la demande en cours à réinjecter avant la queue (voir
// compactMessagesOpt), quand un résumé a été obtenu ; nil sinon.
func compactPending(msgs, torso []Message, tailStart int) []Message {
for _, m := range msgs[tailStart:] {
if m.Role == "user" && !isLokiInjected(m) {
return nil
}
}
for i := len(torso) - 1; i >= 0; i-- {
if torso[i].Role != "user" || isLokiInjected(torso[i]) {
continue
}
if strings.HasPrefix(msgText(torso[i]), compactSummaryPrefix) {
continue // résumé d'une compaction précédente, pas une demande
}
return []Message{torso[i]}
}
return nil
}
// compactAssemble : tête, zone résumée, demande réinjectée, queue.
func compactAssemble(msgs []Message, head, tailStart int, mid, pending []Message) []Message {
out := make([]Message, 0, head+len(mid)+len(pending)+len(msgs)-tailStart)
out = append(out, msgs[:head]...)
out = append(out, mid...)
out = append(out, pending...)
return append(out, msgs[tailStart:]...)
}
// compactHopeless : la compaction serait refusée par la garantie de réduction
// quel que soit le résumé. Le plancher est la sortie avec un résumé VIDE —
// message d'en-tête court, sans index ni critères, réponse assistant, demande
// réinjectée, queue : toute vraie sortie a la même forme avec des messages au
// moins aussi longs, donc une estimation au moins aussi grande.
func compactHopeless(msgs []Message, head, tailStart int) bool {
torso := msgs[head:tailStart]
floor := compactAssemble(msgs, head, tailStart, []Message{
{Role: "user", Content: compactSummaryUserMsg("", nil)},
{Role: "assistant", Content: compactAckMsg},
}, compactPending(msgs, torso, tailStart))
return estimateTokens(floor) > estimateTokens(msgs)*4/5
}
// summaryLooksEmpty détecte un résumé raté : trop court, ou constitué seulement
@@ -666,13 +771,7 @@ func renderTranscript(msgs []Message) string {
// summarizeResp modélise le sous-ensemble utile d'une réponse non-streamée de
// /v1/chat/completions.
type summarizeResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Choices []summaryChoice `json:"choices"`
}
// summarizeTranscript demande au modèle local un résumé dense et fidèle du torse.
@@ -691,13 +790,17 @@ This is a CODING session. Also keep:
- Errors hit (build, tests, tools) and how each was resolved — or that it is still open
- The commands that build and test the project`
// summarizeTranscriptFor : code = résumé d'une session du mode Code.
func summarizeTranscriptFor(ctx context.Context, transcript string, code bool) (string, error) {
sys := `You are a context compactor. You are given the transcript of the older turns of a conversation between a user and an AI assistant (with its tools). The PURPOSE of your summary is to let the conversation continue in a fresh, smaller context WITHOUT losing any information that is useful or important to understand what came before and keep working — preserve everything that matters, drop only what is redundant.
// compactTranscriptIntro : le rôle du résumeur sur transcription. Les règles
// qui suivent (compactSummaryRules) servent aussi au résumé en continuation
// (chat_compact_cont.go) : les deux chemins gardent les mêmes exigences.
const compactTranscriptIntro = `You are a context compactor. You are given the transcript of the older turns of a conversation between a user and an AI assistant (with its tools). The PURPOSE of your summary is to let the conversation continue in a fresh, smaller context WITHOUT losing any information that is useful or important to understand what came before and keep working — preserve everything that matters, drop only what is redundant.
The assistant is MID-TASK: it will read your summary and must resume exactly where it left off, WITHOUT redoing work it has already done. Its own internal reasoning is NOT part of the transcript and is lost — your summary is the only memory it keeps.
Summarize densely and faithfully, keeping ONLY the essentials:
`
// compactSummaryRules : ce que tout résumé de compaction doit garder.
const compactSummaryRules = `Summarize densely and faithfully, keeping ONLY the essentials:
- The user's CURRENT request, goal(s) and constraints
- FINDINGS: the concrete information already gathered — facts, figures, dates, names, URLs, file paths, values, config. This is the most important part: whatever is not here is lost and will have to be looked up again.
- Sources already consulted (URLs opened, files read, commands run) — so they are not consulted a second time
@@ -705,6 +808,10 @@ Summarize densely and faithfully, keeping ONLY the essentials:
- STATE OF PROGRESS: what is already answered, what is still missing, and the next concrete step
Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Be as concise as you can WHILE keeping every fact, decision and still-open task: a detail you drop here is lost for good, so when in doubt keep it. This is a dense compression summary, not a report. Always write ACTUAL prose sentences/bullets — never answer with just an id or a reference.
Write the summary in the SAME language as the conversation.`
// summarizeTranscriptFor : code = résumé d'une session du mode Code.
func summarizeTranscriptFor(ctx context.Context, transcript string, code bool) (string, error) {
sys := compactTranscriptIntro + compactSummaryRules
if code {
sys += codeSummaryRules
}
@@ -735,19 +842,59 @@ Write the summary in the SAME language as the conversation.`
// argument inconnu par un 400, et la compaction échouait à chaque fois.
delete(payload, "chat_template_kwargs")
}
// SIDE_SLOT : la transcription est un prompt neuf, calculé à froid — sur le
// second slot, qui fait CTX jetons comme le premier, elle laisse intact le
// slot de la discussion. Sans second slot en service, aucun id_slot.
setEngineSlot(payload, engineSlotFor(ep, false))
ch, err := postSummary(ctx, ep, payload, perfTag{kind: perfCompact, conv: perfTagOf(ctx).conv})
if err != nil {
return "", err
}
return cleanSummary(ch.Message.Content, ch.Message.ReasoningContent, ch.FinishReason)
}
// summaryChoice : le premier choix d'une réponse de résumé.
type summaryChoice struct {
Message struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
// Appels d'outil émis malgré tool_choice « none » (résumé en
// continuation, qui garde les outils du tour) : jamais un résumé.
ToolCalls []json.RawMessage `json:"tool_calls"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
}
// summaryHTTPError : le moteur a refusé la requête de résumé.
type summaryHTTPError struct {
who string
status int
body string
}
func (e *summaryHTTPError) Error() string {
return fmt.Sprintf("résumé: %s %d: %s", e.who, e.status, e.body)
}
// postSummary envoie une requête de résumé non streamée et rend son premier
// choix. Partagé par le résumé sur transcription et celui en continuation
// (chat_compact_cont.go) : même compteur de requêtes en vol, même télémétrie
// (kind=compact), mêmes erreurs.
func postSummary(ctx context.Context, ep chatEndpoint, payload map[string]any, tag perfTag) (summaryChoice, error) {
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
if err != nil {
return "", err
return summaryChoice{}, err
}
req.Header.Set("Content-Type", "application/json")
ep.auth(req.Header.Set)
if !ep.External {
defer engineRequestStart()() // voir llm_slots.go
end, _ := engineRequestBeginSide(nil, payloadOnSideSlot(payload)) // voir llm_slots.go
defer end()
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", friendlyLLMError(err)
return summaryChoice{}, friendlyLLMError(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
@@ -756,28 +903,27 @@ Write the summary in the SAME language as the conversation.`
if ep.External {
who = "API externe"
}
return "", fmt.Errorf("résumé: %s %d: %s", who, resp.StatusCode, strings.TrimSpace(string(b)))
return summaryChoice{}, &summaryHTTPError{who: who, status: resp.StatusCode, body: strings.TrimSpace(string(b))}
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
return summaryChoice{}, err
}
// Decoder et non Unmarshal : comme avant, seule la première valeur JSON
// compte — un octet parasite après elle (proxy, passerelle) ne doit pas
// faire échouer une compaction qui passait.
var out summarizeResp
if err := json.NewDecoder(bytes.NewReader(raw)).Decode(&out); err != nil {
return "", err
return summaryChoice{}, err
}
// Télémétrie de la compaction : réponse non streamée, timings et usage sont
// à la racine. Lue à part et sans échec possible (perfWire) : un compteur
// mal typé ne doit jamais faire échouer la compaction.
perfRecord(perfRecFromWire(perfTag{kind: perfCompact, conv: perfTagOf(ctx).conv}, decodePerfWire(raw)), nil)
perfRecord(perfRecFromWire(tag, decodePerfWire(raw)), nil)
if len(out.Choices) == 0 {
return "", fmt.Errorf("résumé: réponse vide")
return summaryChoice{}, fmt.Errorf("résumé: réponse vide")
}
ch := out.Choices[0]
return cleanSummary(ch.Message.Content, ch.Message.ReasoningContent, ch.FinishReason)
return out.Choices[0], nil
}
// summaryRuneCap borne le résumé en caractères, en filet de sécurité
@@ -888,6 +1034,10 @@ const shrinkTruncMarker = "\n[…tronqué : contexte plein. Relance l'outil de f
// typiquement un tour qui a lu beaucoup de gros fichiers d'un coup, tout est
// dans la queue protégée). Avant, l'erreur 400 remontait telle quelle et la
// conversation restait bloquée. Ici on réduit pour de bon :
// 0. le raisonnement renvoyé (REASONING_ECHO), du plus ancien au plus récent :
// c'est ce que Loki n'envoyait pas sans la clé, à retirer avant tout le reste
// — y compris dans un seul long tour sans frontière `user`, que l'étape 2
// ne sait pas couper ;
// 1. les gros résultats d'outils (du plus ancien au plus récent) sont tronqués ;
// 2. si ça ne suffit pas, on retire les plus vieux échanges, par tour entier
// (frontière `user`), en gardant les messages système de tête.
@@ -905,6 +1055,21 @@ func shrinkToFit(msgs []Message, actual int) ([]Message, bool) {
}
target := int(float64(ctxWindow()) * 0.6 / ratio)
out := append([]Message(nil), msgs...)
// Avant tout : les images gardées par KEEP_TURN_IMAGES, retirées en bloc
// comme à la compaction (sans la clé, aucune).
if s, freed := dropKeptImages(out); freed > 0 {
logKeptImagesDropped("réduction", freed)
out = s
}
for i := range out {
if out[i].ReasoningContent == "" && out[i].ReasoningModel == "" {
continue
}
if estimateTokens(out) <= target {
break
}
out[i].ReasoningContent, out[i].ReasoningModel = "", ""
}
for i := range out {
if estimateTokens(out) <= target {
break
+476
View File
@@ -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
}
+680
View File
@@ -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")
}
}
+94 -28
View File
@@ -67,6 +67,10 @@ type Conversation struct {
// persisté : un rechargement repart du seul CtxUsed, comme avant.
ctxUsedLen int
genPeak int
// ProjSnap : bloc projet figé de la discussion et dernier état annoncé au
// modèle (PROJ_SNAPSHOT, chat_projsnap.go). nil = pas d'instantané : le
// prochain tour en prend un. Toujours nil sans la clé (JSON inchangé).
ProjSnap *projSnapshot `json:"proj_snap,omitempty"`
Generating bool `json:"-"`
cancel context.CancelFunc // annule la génération en cours (/stop)
@@ -80,6 +84,10 @@ type Conversation struct {
// benching : le verrou est tenu par un benchmark (llm_bench_job.go). Même
// logique que la tâche : l'interface et les refus disent qui occupe le moteur.
benching bool
// leaseWhy : le refus à donner tant que benching tient le verrou —
// errBenchBusy pour un bench, errTuneBusy pour l'optimiseur, qui prend le
// même verrou (backend_tune_job.go).
leaseWhy error
// File d'attente des messages envoyés PENDANT une génération (AJEAN
// 0.14.0). Ils sont injectés dans le tour en cours à la prochaine frontière
@@ -182,6 +190,8 @@ func LoadConversation() {
}
conv.mu.Lock()
defer conv.mu.Unlock()
// Unmarshal ne remet pas à zéro un champ absent du JSON.
conv.ProjSnap = nil
_ = json.Unmarshal(b, conv)
// Guérit les conversations d'avant le passage des captures en éphémère : un
// base64 d'image persisté était rejoué à chaque tour et dépassait le contexte.
@@ -212,6 +222,10 @@ func (c *Conversation) loadFrom(id string, b []byte) {
}
c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0
c.ctxUsedLen, c.genPeak = 0, 0
// Bloc figé de l'ancienne discussion : json.Unmarshal ne remet pas à zéro un
// champ absent, une discussion neuve (ou enregistrée sans lui) l'aurait hérité
// — avec la description et l'index d'un autre projet.
c.ProjSnap = nil
c.queued = nil // file de l'ancienne discussion : elle ne suit pas la bascule
if len(b) > 0 {
_ = json.Unmarshal(b, c)
@@ -379,9 +393,12 @@ func compactLog(log []LogEvent) []LogEvent {
// messages donne ce que l'estimation ne voit pas (prompt système injecté,
// schémas d'outils, gabarit de chat). On le rajoute à l'estimation d'après
// compaction, sinon la jauge s'effondre puis resaute au tour suivant.
func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase string, msgs []Message, ctxUsed int, caps Caps) ([]Message, bool) {
//
// opt : options de COMPACT_CONTINUATION (chat_compact_cont.go) ; nulles, le
// chemin d'avant.
func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase string, msgs []Message, ctxUsed int, caps Caps, opt compactOpts) ([]Message, bool) {
c.appendDelta(epoch, map[string]any{"compacting": true})
compacted, changed := compactMessages(ctx, msgs, caps)
compacted, changed := compactMessagesNoted(ctx, msgs, caps, opt)
c.appendDelta(epoch, map[string]any{"compacting": false})
logCompact(phase, ctxUsed, msgs, compacted, changed)
if !changed {
@@ -397,6 +414,12 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
if caps.Agent {
compacted = remindReadMemPages(compacted, msgs)
}
// PROJ_SNAPSHOT : le début du prompt est réécrit, le bloc projet sera donc
// repris tout neuf au prochain tour (ProjSnap = nil). Les mises à jour déjà
// livrées le suivraient dans la queue gardée telle quelle, une vieille valeur
// après la fraîche : retirées ici, elles n'apprennent rien que le bloc neuf
// ne dise. Sans la clé, rien à retirer.
compacted = stripContextUpdates(compacted)
overhead := ctxUsed - estimateTokens(msgs)
if overhead < 0 {
overhead = 0
@@ -405,6 +428,7 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
c.mu.Lock()
if c.epoch == epoch {
c.Messages = compacted
c.ProjSnap = nil
c.CtxUsed = est // le vrai compte reviendra avec les stats du prochain tour
c.ctxUsedLen = len(compacted)
}
@@ -455,9 +479,12 @@ var ErrBusy = fmt.Errorf("génération en cours")
// nomme la tâche et on dit comment reprendre la main.
func (c *Conversation) busyReason() error {
c.mu.Lock()
name, bench := c.runningTaskName, c.benching
name, bench, why := c.runningTaskName, c.benching, c.leaseWhy
c.mu.Unlock()
if bench {
if why != nil {
return why
}
return errBenchBusy
}
if name == "" {
@@ -477,6 +504,11 @@ var errModelLoading = fmt.Errorf("⏳ Le modèle est encore en train de charger
// MODÈLE en tête du message, mais rendues comme pastilles dans la bulle : le fil
// doit montrer ce que l'utilisateur a écrit, pas la consigne qu'on ajoute pour lui.
func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, temperature float64) error {
// Optimisation en cours (backend_tune_lock.go) : le vrai moteur est arrêté,
// « le modèle charge » serait faux — on dit pourquoi.
if err := tuneGuard(); err != nil {
return err
}
if !healthCheck() {
return errModelLoading
}
@@ -495,20 +527,19 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
if strings.TrimSpace(prompt) == "" {
prompt = "Prends-en connaissance."
}
askedCaps := caps
// Mode code : fige le rôle du tour (plan demandé → planner, sinon builder).
// La détection de bascule (puce « passer en mode Code ? ») est émise après
// la bulle utilisateur, plus bas.
if caps.Code && caps.Role == "" {
caps.Role = "builder"
if wantsPlan(text) {
caps.Role = "planner"
}
}
caps = withTurnRole(caps, text)
// Content = simple texte d'ordinaire ; format multimodal (texte + images) quand
// la vision est active et qu'une pièce jointe est une image (userMessageContent).
c.Messages = append(c.Messages, Message{Role: "user", Content: userMessageContent(files, prompt)})
epoch := c.epoch
c.mu.Unlock()
// Capacités demandées pour ce tour, retenues en mémoire (jamais persistées)
// pour préchauffer le suivant (PREWARM, chat_prewarm.go).
prewarmNoteCaps(convActiveID(), askedCaps)
// Borne de tour + bulle utilisateur (rejouables). Persistée tout de suite
// (sur disque quelques ms plus tard, voir persistAsync) : si le process
@@ -746,9 +777,15 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// (pas d'extrait de réponse) : la notif transite par Apple/Google. Pas
// de notification après un « stop » (retour ci-dessus) : l'utilisateur
// est là et a coupé volontairement.
if !c.startQueuedIfAny() && hasPushSubs() {
started := c.startQueuedIfAny()
if !started && hasPushSubs() {
go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(time.Since(turnStart)))
}
// Plus rien ne suit : le prochain tour peut être préparé pendant que
// l'utilisateur lit (PREWARM, off par défaut — sans la clé, rien ne part).
if !started {
c.prewarmKick(prewarmTurnEnd)
}
}()
// Télémétrie : tout ce que ce tour envoie au moteur (étapes, compaction,
// sous-agents, vérification) est rattaché à la discussion active.
@@ -772,11 +809,20 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// d'affichage garde le fil complet. Le résumé est un appel modèle non streamé :
// il bloque plusieurs secondes AVANT que la vraie réponse commence, d'où la
// bannière de progression émise par compactAndPublish.
// KEEP_TURN_IMAGES (chat_keep_images.go) : images gardées retirées d'abord
// si le contexte le demande ou si elles ne doivent plus partir. Sans la
// clé, il n'y en a aucune : rien ne change.
msgs, ctxUsed = c.keptImagesTurnStart(epoch, msgs, ctxUsed, peak)
// Estimation de début de tour, comparée au premier compte réel du moteur
// (journal [ctx], seulement au-delà de 2 % d'écart).
startEst := ctxUsed
if compactNeeded(msgs, ctxUsed, peak) {
if out, changed := c.compactAndPublish(ctx, epoch, "début-tour", msgs, ctxUsed, caps); changed {
// COMPACT_CONTINUATION (off par défaut) : un refus récent n'est pas retenté
// tout de suite, et le résumé peut prolonger le prompt en cache. Sans la
// clé, ni l'un ni l'autre.
conv := perfTagOf(ctx).conv
if compactNeeded(msgs, ctxUsed, peak) && !compactRefusedSkip(conv, ctxUsed, msgs) {
opt := c.compactOptsFor(caps, msgs, ctxUsed, conv)
if out, changed := c.compactAndPublish(ctx, epoch, "début-tour", msgs, ctxUsed, caps, opt); changed {
msgs = out
// Le chiffre d'avant compaction ne dit plus rien de la requête qui
// part : on compare celui que la compaction vient de poser.
@@ -797,21 +843,22 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// agent, on veut parler au modèle nu, comme à un llama-server direct. Le
// prompt du preset et l'index mémoire décrivent des outils que ce mode n'a
// pas : le modèle se mettait à écrire des <tool_call> en clair.
final := msgs
if caps.Agent {
final = append(projectSystemMessages(), msgs...)
if m, ok := codeInstructionsMessage(caps); ok {
final = append([]Message{m}, final...)
}
if sp := readSysPrompt(); sp != "" {
final = append([]Message{{Role: "system", Content: sp}}, final...)
}
//
// Assemblage dans turnView (chat_projsnap.go) : avec PROJ_SNAPSHOT, le bloc
// projet est celui, figé, de la discussion, et ses changements partent en
// tête de ce message — après la compaction de début de tour, sous c.mu.
sent, tools, snapTurn := c.turnView(caps, epoch, msgs, caps.Agent)
runCtx := ctx
if snapTurn != nil {
runCtx = withProjSnapTurn(ctx, snapTurn)
}
// newBase : vue modèle publiée par une compaction survenue PENDANT le tour.
// Non-nil = elle remplace l'historique (elle contient déjà le tour en cours).
var newBase []Message
var content strings.Builder
// echo : raisonnement de la réponse finale (REASONING_ECHO), rangé avec elle.
var echo *ReasoningEcho
// Le comptage EXACT du contexte vient de `usage.prompt_tokens` (option
// include_usage). Tous les moteurs ne le renvoient pas — un llama-server
// récent a cessé de le faire, et la jauge est restée bloquée à zéro sur des
@@ -823,8 +870,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// asked : le tour s'est terminé sur une question à l'utilisateur (outil ask) —
// pas de vérification du mode Code avant sa réponse.
asked := false
sent, tools := prepareTurn(final, caps)
extra, _ := runChatTools(ctx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
extra, _ := runChatTools(runCtx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
switch {
case ev.Err != nil:
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
@@ -876,6 +922,8 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
base = base[1:]
}
newBase = append([]Message(nil), base...)
case ev.Echo != nil:
echo = ev.Echo
case ev.Compacting != nil:
// Compaction déclenchée pendant la boucle d'outils : même bannière que la
// compaction de début de tour.
@@ -923,9 +971,14 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
if newBase != nil {
c.Messages = newBase
}
// Bloc projet rafraîchi par une compaction en cours de tour
// (PROJ_SNAPSHOT) : c'est lui que le tour suivant renvoie.
if s := snapTurn.takeRefreshed(); s != nil {
c.ProjSnap = s
}
c.Messages = append(c.Messages, extra...)
if s := content.String(); strings.TrimSpace(s) != "" {
c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
c.Messages = append(c.Messages, withEcho(Message{Role: "assistant", Content: s}, echo))
}
// Rappel de loki resté sans réponse (stop, erreur) ou collé à un autre
// user : retiré, sinon deux `user` d'affilée au tour suivant.
@@ -980,12 +1033,20 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// retombe tout de suite et le tour suivant démarre avec de la marge.
// Sauf si un Reset est passé (rien à compacter) ou si le tour a été annulé
// (bouton stop) : on n'enchaîne pas plusieurs secondes de résumé sur un stop.
if stale || ctx.Err() != nil || !compactNeeded(msgs, ctxUsed, peak) {
// KEEP_TURN_IMAGES : comme en début de tour, les images gardées partent
// d'abord si le contexte le demande, et le résumé ne suit que s'il reste
// nécessaire. Sans la clé, aucune image gardée : rien ne change.
if !stale && ctx.Err() == nil {
msgs, ctxUsed = c.keptImagesTurnStart(epoch, msgs, ctxUsed, peak)
}
if stale || ctx.Err() != nil || !compactNeeded(msgs, ctxUsed, peak) || compactRefusedSkip(conv, ctxUsed, msgs) {
return
}
// context.Background() et non ctx : le tour est terminé, son contexte peut
// être annulé alors que cette compaction-là doit aller au bout.
c.compactAndPublish(context.Background(), epoch, "fin-tour", msgs, ctxUsed, caps)
// être annulé alors que cette compaction-là doit aller au bout. Après une
// vérification du mode Code, le slot porte son prompt : pas de
// continuation (engineSlotHolds), transcription comme avant.
c.compactAndPublish(context.Background(), epoch, "fin-tour", msgs, ctxUsed, caps, c.compactOptsFor(caps, msgs, ctxUsed, conv))
}
// ctxNowLocked : contexte à juger pour compacter, c.mu tenu. llama-server
@@ -1006,6 +1067,9 @@ func (c *Conversation) ctxNowLocked(msgs []Message, local bool) int {
// événements passent par le flux d'abonnement, donc tous les appareils voient la
// progression. Renvoie ErrBusy si un tour est déjà en cours.
func (c *Conversation) CompactNow() error {
if err := tuneGuard(); err != nil {
return err
}
if !healthCheck() {
return errModelLoading
}
@@ -1034,7 +1098,7 @@ func (c *Conversation) CompactNow() error {
}
c.mu.Unlock()
}()
if _, changed := c.compactAndPublish(ctx, epoch, "manuel", msgs, lastReal, Caps{}); changed {
if _, changed := c.compactAndPublish(ctx, epoch, "manuel", msgs, lastReal, Caps{}, compactOpts{}); changed {
c.persist()
}
}()
@@ -1076,9 +1140,11 @@ func (c *Conversation) Reset() {
c.Seq = 0
c.CtxUsed = 0
c.ctxUsedLen, c.genPeak = 0, 0
c.ProjSnap = nil
c.epoch++
c.Generating = false
c.benching = false // Stop a annulé le bench : le verrou est rendu ici
c.leaseWhy = nil
c.cancel = nil
c.queued = nil // les messages en attente visaient le fil qu'on vient de vider
c.cond.Broadcast()
+4 -2
View File
@@ -103,8 +103,10 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
Version: Version,
ExportedAt: time.Now().Format(time.RFC3339),
CtxUsed: ctxUsed,
Messages: stripInlineImages(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, "", " ")
}
+434
View File
@@ -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
}
+607
View File
@@ -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)
}
}
+34
View File
@@ -74,6 +74,11 @@ type convPersister struct {
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 (
@@ -92,6 +97,9 @@ func newConvPersister() *convPersister {
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
@@ -157,6 +165,7 @@ func (p *convPersister) run() {
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
@@ -166,6 +175,10 @@ func (p *convPersister) run() {
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
@@ -225,6 +238,13 @@ func (p *convPersister) dropToolRes(prefix string) {
}
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)
@@ -239,6 +259,17 @@ func (p *convPersister) dropToolResLocked(prefix string) {
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()
@@ -338,6 +369,9 @@ func commitPersistBatch(path string, snaps []*convSnap, res []toolResJob) ([]*co
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
+23
View File
@@ -187,6 +187,29 @@ func TestToolResultSupprimeAvantEcriture(t *testing.T) {
}
}
// 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) {
+450
View File
@@ -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)
}
}
+525
View File
@@ -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")
}
}
+716
View File
@@ -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
}
+627
View File
@@ -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)
}
}
+5
View File
@@ -171,6 +171,9 @@ func convSummary(msgs []Message) string {
if m.Role != "user" || isLokiInjected(m) {
continue
}
// Le titre vient de ce qu'a écrit l'utilisateur, pas d'une mise à jour
// du projet posée en tête par Loki (PROJ_SNAPSHOT).
m, _ = withoutCtxUpdate(m)
s, _ := m.Content.(string)
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
if s == "" {
@@ -217,6 +220,7 @@ func projectSwitch(slug string) error {
}
// convIndex rend la plus récemment modifiée en tête.
convActivate(list[0].ID, storeBytes(bkChat, convKey(list[0].ID)))
prewarmAfterSwitch()
return nil
}
@@ -244,6 +248,7 @@ func convSwitch(id string) error {
}
conv.persist() // fige la discussion qu'on quitte
convActivate(id, storeBytes(bkChat, convKey(id)))
prewarmAfterSwitch()
return nil
}
+7
View File
@@ -127,6 +127,13 @@ func baseSystemPrompt(caps Caps, tools []Tool) string {
if caps.Agent {
b.WriteString(mcpPromptLine(tools))
}
// PROJ_SNAPSHOT (chat_projsnap.go) : le bloc projet est figé, ses changements
// arrivent en tête des messages suivants. Une ligne fixe, seulement avec la
// clé, pour qu'ils ne passent ni pour la parole de l'utilisateur ni pour
// moins récents que le bloc.
if caps.Agent && projSnapEnabled(ReadConfig()) {
b.WriteString(projSnapSystemLine)
}
b.WriteString("\nDate: " + time.Now().Format("2006-01-02"))
return b.String()
}
+527
View File
@@ -0,0 +1,527 @@
package loki
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"io"
"maps"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Sonde de gabarit : que fait le gabarit de chat du modèle chargé de ce qu'on
// lui envoie ? Deux questions, celles dont dépendent les pistes de réutilisation
// du cache :
//
// - preservesHistory : le raisonnement d'un message assistant PASSÉ (avant le
// dernier message utilisateur) est-il encore rendu ? Les gabarits Qwen3 le
// retirent, d'autres le gardent.
// - prefixStable : quand un message utilisateur s'ajoute après un tour
// d'outil, le rendu précédent reste-t-il un préfixe exact du nouveau ? Sinon
// le moteur recalcule depuis l'endroit où les deux divergent, quoi que fasse
// son cache.
//
// Plus une troisième, déduite des mêmes rendus : rendersReasoning, le gabarit
// rend-il reasoning_content quelque part ?
//
// DIAGNOSTIQUE d'abord. Rien ici ne construit un prompt : preservesHistory
// décrit le gabarit, pas ce que Loki fait. Seul consommateur : REASONING_ECHO
// (llm_reasoning_echo.go, opt-in), qui s'abstient de renvoyer un raisonnement
// que le gabarit ne rend jamais, et ne compte celui d'avant la dernière
// question que si le gabarit le garde. Jamais pour AJOUTER quoi que ce soit :
// « unknown » y laisse la décision à la clé, avec ses filets.
//
// Règles de conduite, toutes là pour qu'une sonde ne coûte jamais rien au vrai
// travail :
//
// - moteur LOCAL seulement : un preset externe n'a pas de /apply-template, et
// sonder une API tierce n'apprendrait rien de son gabarit ;
// - POST /apply-template et rien d'autre. Ce rendu est sans état (ni file de
// tâches ni slot), donc il ne peut pas évincer le cache de la conversation.
// Jamais de repli sur une complétion : avec un seul slot, elle effacerait
// précisément le KV qu'on cherche à préserver ;
// - lancée APRÈS une complétion terminée, en tâche de fond, une fois /health à
// 200 : jamais sur le chemin du premier jeton ;
// - les mêmes outils et chat_template_kwargs que la complétion qui l'a
// déclenchée : les gabarits Qwen bifurquent sur enable_thinking, sur
// reasoning_effort et sur la présence d'outils ;
// - add_generation_prompt=false demandé au moteur, plutôt que de deviner à la
// main où commence l'amorce de réponse ;
// - tout échec (404 d'un moteur ancien ou d'un fork, 401, exception du
// gabarit, délai) donne « unknown », jamais un oui ou un non.
type tplTri string
const (
tplYes tplTri = "yes"
tplNo tplTri = "no"
tplUnknown tplTri = "unknown"
)
// tplProbeResult : ce que la sonde a conclu pour un moteur, un modèle, un
// gabarit et une forme de requête donnés. Aucun texte de conversation : les
// messages rendus sont synthétiques, et seuls des verdicts sont gardés.
type tplProbeResult struct {
PreservesHistory tplTri `json:"preserves_history"`
PrefixStable tplTri `json:"prefix_stable"`
// RendersReasoning : reasoning_content apparaît-il dans un rendu, au moins
// juste après la dernière question ? « no » = le gabarit (ou le moteur)
// l'ignore toujours.
RendersReasoning tplTri `json:"renders_reasoning"`
Build string `json:"build,omitempty"`
Model string `json:"model,omitempty"` // nom du fichier, pas le chemin
TemplateHash string `json:"template_hash,omitempty"`
Tools int `json:"tools"`
// Kwargs : chat_template_kwargs avec lesquels les rendus ont été faits.
Kwargs map[string]any `json:"chat_template_kwargs,omitempty"`
Effort string `json:"reasoning_effort,omitempty"`
Note string `json:"note,omitempty"`
At time.Time `json:"at"`
// cacheable : la réponse ne changera pas tant que le moteur, le modèle et le
// gabarit restent les mêmes (rendu obtenu, 404, exception du gabarit). Un
// délai, un 503 ou un 401 se retentent plus tard.
cacheable bool
}
func tplUnknownResult(note string) tplProbeResult {
return tplProbeResult{PreservesHistory: tplUnknown, PrefixStable: tplUnknown, RendersReasoning: tplUnknown, Note: note, At: time.Now()}
}
// tplShape : la forme de la requête à reproduire. Les outils sont figés en JSON
// dès la prise de forme : la goroutine de sonde ne partage rien avec runChat.
type tplShape struct {
tools json.RawMessage // nil = aucun outil envoyé
nTools int
first string // nom du premier outil, pour un appel vraisemblable
kwargs map[string]any
effort string
}
func newTplShape(tools []Tool, kwargs map[string]any, effort string) tplShape {
s := tplShape{nTools: len(tools), effort: effort}
if len(tools) > 0 {
s.tools, _ = json.Marshal(tools)
s.first = tools[0].Function.Name
}
if kwargs != nil {
s.kwargs = maps.Clone(kwargs)
}
return s
}
func (s tplShape) hash() string {
h := fnv.New64a()
h.Write(s.tools)
kw, _ := json.Marshal(s.kwargs) // clés triées : stable
h.Write([]byte{0})
h.Write(kw)
h.Write([]byte{0})
h.Write([]byte(s.effort))
return fmt.Sprintf("%016x", h.Sum64())
}
// Messages synthétiques. Des marqueurs qu'aucun gabarit ne produit de lui-même,
// cherchés tels quels dans le rendu.
const (
tplMarkSys = "loki-sonde-systeme"
tplMarkA = "loki-sonde-question-a"
tplMarkR1 = "loki-sonde-raisonnement-r1"
tplMarkC1 = "loki-sonde-reponse-c1"
tplMarkT1 = "loki-sonde-resultat-t1"
tplMarkB = "loki-sonde-question-b"
// Identifiant d'appel : 9 caractères alphanumériques, la forme qu'exigent les
// gabarits stricts (Mistral) sous peine de raise_exception.
tplCallID = "a1B2c3D4e"
)
type tplMsg struct {
Role string `json:"role"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
const (
tplProbeTimeout = 3 * time.Second // par requête
tplProbeEvery = time.Minute // au plus une vérification par minute
tplProbeCacheMax = 16
tplProbe503Tries = 3
)
var (
// tplProbeAuto : déclenchement automatique après les complétions. Coupé par
// les tests du paquet (TestMain), dont les faux moteurs ne répondent pas
// tous en JSON : la sonde y est appelée explicitement.
tplProbeAuto = true
// Attente entre deux essais sur un 503 (modèle en chargement).
tplProbeBackoff = time.Second
tplProbeMu sync.Mutex
tplProbeCache = map[string]tplProbeResult{}
tplProbeLast *tplProbeResult
tplProbeRunning bool
tplProbeTried time.Time
tplProbeLogged string
)
// tplProbeKick : appelé par runChat après une complétion COMPLÈTE du fil
// principal, sur le moteur local, avec ce qu'elle a envoyé. Ne bloque jamais :
// au plus une sonde en vol, au plus une vérification par minute (la plupart se
// résument à un GET /props qui retrouve la réponse en cache).
func tplProbeKick(tools []Tool, kwargs map[string]any, effort string) {
if !tplProbeAuto {
return
}
tplProbeMu.Lock()
if tplProbeRunning || time.Since(tplProbeTried) < tplProbeEvery {
tplProbeMu.Unlock()
return
}
tplProbeRunning, tplProbeTried = true, time.Now()
tplProbeMu.Unlock()
shape := newTplShape(tools, kwargs, effort)
go func() {
defer func() {
tplProbeMu.Lock()
tplProbeRunning = false
tplProbeMu.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
tplProbeEnsure(ctx, shape)
}()
}
// tplCapsCurrent : le dernier verdict connu, pour l'affichage. Rien pour un
// preset externe : un verdict du moteur local n'y dirait rien de vrai.
func tplCapsCurrent() (tplProbeResult, bool) {
if externalActive() {
return tplProbeResult{}, false
}
tplProbeMu.Lock()
defer tplProbeMu.Unlock()
if tplProbeLast == nil {
return tplProbeResult{}, false
}
return *tplProbeLast, true
}
// tplProbeEnsure : verdict pour la forme donnée, depuis le cache ou une sonde.
func tplProbeEnsure(ctx context.Context, shape tplShape) tplProbeResult {
if resolveChatEndpoint().External {
return tplUnknownResult("preset externe : pas de sonde")
}
p := tplProber{
base: fmt.Sprintf("http://localhost:%d", LLMPort()),
auth: authHeader,
client: &http.Client{Timeout: tplProbeTimeout},
}
if !p.healthy(ctx) {
// Moteur absent ou en chargement : rien à conclure, rien à ranger.
return tplUnknownResult("moteur pas prêt (/health)")
}
props, err := p.props(ctx)
if err != nil {
r := tplUnknownResult("/props illisible : " + err.Error())
tplProbeStore("", r)
return r
}
h := fnv.New64a()
h.Write([]byte(props.ChatTemplate))
tmplHash := fmt.Sprintf("%016x", h.Sum64())
// Clé : le binaire, le modèle ET le gabarit effectivement chargé — un
// --chat-template d'EXTRA_ARGS change le dernier sans toucher aux deux
// autres. Plus la forme de la requête, dont le rendu dépend.
key := props.BuildInfo + "\x00" + props.ModelPath + "\x00" + tmplHash + "\x00" + shape.hash()
tplProbeMu.Lock()
if r, ok := tplProbeCache[key]; ok {
tplProbeLast = &r
tplProbeMu.Unlock()
return r
}
tplProbeMu.Unlock()
r := p.probe(ctx, shape)
r.Build, r.TemplateHash, r.Tools, r.Kwargs, r.Effort = props.BuildInfo, tmplHash, shape.nTools, shape.kwargs, shape.effort
if props.ModelPath != "" {
r.Model = filepath.Base(props.ModelPath)
}
// Une ligne par verdict nouveau : un échec passager qui se répète chaque
// minute à l'identique n'inonde pas le journal.
line := tplProbeLine(r)
tplProbeMu.Lock()
fresh := line != tplProbeLogged
tplProbeLogged = line
tplProbeMu.Unlock()
if fresh {
fmt.Fprintln(os.Stderr, line)
}
tplProbeStore(key, r)
return r
}
func tplProbeStore(key string, r tplProbeResult) {
tplProbeMu.Lock()
defer tplProbeMu.Unlock()
tplProbeLast = &r
if key == "" || !r.cacheable {
return
}
if len(tplProbeCache) >= tplProbeCacheMax {
oldest := ""
for k, v := range tplProbeCache {
if oldest == "" || v.At.Before(tplProbeCache[oldest].At) {
oldest = k
}
}
delete(tplProbeCache, oldest)
}
tplProbeCache[key] = r
}
func tplProbeLine(r tplProbeResult) string {
s := fmt.Sprintf("[tplprobe] build=%s modèle=%s gabarit=%s outils=%d historique_raisonnement=%s préfixe_stable=%s raisonnement_rendu=%s",
orDash(r.Build), orDash(r.Model), orDash(r.TemplateHash), r.Tools, r.PreservesHistory, r.PrefixStable, r.RendersReasoning)
if r.Note != "" {
s += " (" + r.Note + ")"
}
return s
}
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}
type tplProber struct {
base string
auth func(*http.Request)
client *http.Client
}
// tplHTTPError : réponse non-200 du moteur.
type tplHTTPError struct {
status int
body string
}
func (e *tplHTTPError) Error() string {
if e.body != "" {
return fmt.Sprintf("HTTP %d : %s", e.status, e.body)
}
return fmt.Sprintf("HTTP %d", e.status)
}
// tplDefinitive : l'échec se reproduira à l'identique tant que moteur, modèle
// et gabarit ne changent pas (route absente, gabarit qui lève une exception,
// requête refusée). Un 401/403 dépend de la clé, un 503 du chargement, un
// délai de la charge : ceux-là se retentent.
func tplDefinitive(err error) bool {
var he *tplHTTPError
if !errors.As(err, &he) {
return false
}
switch he.status {
case http.StatusUnauthorized, http.StatusForbidden, http.StatusServiceUnavailable, http.StatusTooManyRequests:
return false
}
return he.status >= 400 && he.status < 600
}
func (p tplProber) do(ctx context.Context, method, path string, body []byte) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, tplProbeTimeout)
defer cancel()
var rd io.Reader
if body != nil {
rd = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, p.base+path, rd)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
// Un moteur protégé par API_KEY répond 401 sans la clé (le piège déjà
// payé par la sonde de vision, chat_screenshot.go).
if p.auth != nil {
p.auth(req)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
msg := strings.TrimSpace(string(data))
if rs := []rune(msg); len(rs) > 160 {
msg = string(rs[:160]) + "…"
}
return nil, &tplHTTPError{status: resp.StatusCode, body: msg}
}
return data, nil
}
func (p tplProber) healthy(ctx context.Context) bool {
_, err := p.do(ctx, http.MethodGet, "/health", nil)
return err == nil
}
type tplProps struct {
BuildInfo string `json:"build_info"`
ModelPath string `json:"model_path"`
ChatTemplate string `json:"chat_template"`
}
func (p tplProber) props(ctx context.Context) (tplProps, error) {
var out tplProps
data, err := p.do(ctx, http.MethodGet, "/props", nil)
if err != nil {
return out, err
}
if err := json.Unmarshal(data, &out); err != nil {
return out, fmt.Errorf("réponse non JSON")
}
return out, nil
}
// render : POST /apply-template, relancé sur 503 (modèle en chargement).
func (p tplProber) render(ctx context.Context, shape tplShape, msgs []tplMsg, genPrompt bool) (string, error) {
body := map[string]any{"messages": msgs, "add_generation_prompt": genPrompt}
// Mêmes clés que runChat, et seulement celles qui atteignent le gabarit.
if shape.tools != nil {
body["tools"] = shape.tools
body["parallel_tool_calls"] = false
}
if shape.kwargs != nil {
body["chat_template_kwargs"] = shape.kwargs
}
if shape.effort != "" {
body["reasoning_effort"] = shape.effort
}
raw, err := json.Marshal(body)
if err != nil {
return "", err
}
for try := 1; ; try++ {
data, err := p.do(ctx, http.MethodPost, "/apply-template", raw)
var he *tplHTTPError
if err != nil && errors.As(err, &he) && he.status == http.StatusServiceUnavailable && try < tplProbe503Tries {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(time.Duration(try) * tplProbeBackoff):
}
continue
}
if err != nil {
return "", err
}
var out struct {
Prompt *string `json:"prompt"`
}
if json.Unmarshal(data, &out) != nil || out.Prompt == nil {
return "", &tplHTTPError{status: http.StatusOK, body: "réponse sans prompt"}
}
return *out.Prompt, nil
}
}
// probe : les rendus et leurs verdicts. Chaque verdict ne vaut que si ses
// propres rendus ont abouti ; l'autre peut rester inconnu.
func (p tplProber) probe(ctx context.Context, shape tplShape) tplProbeResult {
r := tplUnknownResult("")
definitive := true
var notes []string
fail := func(what string, err error) {
if !tplDefinitive(err) {
definitive = false
}
notes = append(notes, what+" : "+err.Error())
}
sys := tplMsg{Role: "system", Content: tplMarkSys}
userA := tplMsg{Role: "user", Content: tplMarkA}
userB := tplMsg{Role: "user", Content: tplMarkB}
// 1) Raisonnement d'un tour passé : [système, A, assistant{R1, C1}, B].
hist, err := p.render(ctx, shape, []tplMsg{sys, userA,
{Role: "assistant", Content: tplMarkC1, ReasoningContent: tplMarkR1}, userB}, false)
switch {
case err != nil:
fail("historique", err)
case !strings.Contains(hist, tplMarkC1) || !strings.Contains(hist, tplMarkB):
notes = append(notes, "historique : rendu incohérent")
case strings.Contains(hist, tplMarkR1):
r.PreservesHistory = tplYes
default:
r.PreservesHistory = tplNo
}
// 2) Stabilité du préfixe à travers un tour d'outil bien formé : un appel
// avec identifiant, puis son résultat, puis un nouveau message utilisateur.
name := shape.first
if name == "" {
name = "lookup"
}
call := tplMsg{Role: "assistant", Content: tplMarkC1, ReasoningContent: tplMarkR1,
ToolCalls: []ToolCall{{ID: tplCallID, Type: "function", Function: ToolCallFunc{Name: name, Arguments: "{}"}}}}
result := tplMsg{Role: "tool", Content: tplMarkT1, ToolCallID: tplCallID}
before := []tplMsg{sys, userA, call, result}
after := append(append([]tplMsg{}, before...), userB)
r1, err1 := p.render(ctx, shape, before, false)
var r1g, r2 string
var err2, err3 error
if err1 == nil {
r1g, err2 = p.render(ctx, shape, before, true)
}
if err1 == nil && err2 == nil {
r2, err3 = p.render(ctx, shape, after, false)
}
switch {
case err1 != nil:
fail("préfixe", err1)
case err2 != nil:
fail("préfixe", err2)
case err3 != nil:
fail("préfixe", err3)
case r1 == r1g:
// Un moteur qui ignore add_generation_prompt rend l'amorce de réponse
// dans les deux cas : la comparaison conclurait à tort « instable ».
notes = append(notes, "add_generation_prompt ignoré par le moteur")
case !strings.Contains(r1, tplMarkT1) || !strings.Contains(r2, tplMarkB):
notes = append(notes, "préfixe : rendu incohérent")
case strings.HasPrefix(r2, r1):
r.PrefixStable = tplYes
default:
r.PrefixStable = tplNo
}
switch {
case r.PreservesHistory == tplYes || (err1 == nil && strings.Contains(r1, tplMarkR1)):
r.RendersReasoning = tplYes
case err1 == nil && strings.Contains(r1, tplMarkT1) && r.PreservesHistory == tplNo:
// Même au dernier tour le raisonnement n'apparaît pas : c'est le moteur
// (ou le gabarit) qui ignore reasoning_content, pas un tri par position.
r.RendersReasoning = tplNo
notes = append(notes, "reasoning_content jamais rendu")
}
r.Note = strings.Join(notes, " ; ")
r.cacheable = definitive
return r
}
+449
View File
@@ -0,0 +1,449 @@
package loki
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"regexp"
"strings"
"sync"
"testing"
"time"
)
// Les faux moteurs des autres tests ne répondent pas tous en JSON sur /props :
// la sonde automatique (lancée après chaque complétion du fil principal) y est
// coupée, et appelée explicitement ici.
func TestMain(m *testing.M) {
tplProbeAuto = false
os.Exit(m.Run())
}
func freshTplProbe(t *testing.T) {
t.Helper()
reset := func() {
tplProbeMu.Lock()
tplProbeCache = map[string]tplProbeResult{}
tplProbeLast = nil
tplProbeRunning = false
tplProbeTried = time.Time{}
tplProbeLogged = ""
tplProbeMu.Unlock()
}
reset()
old := tplProbeBackoff
tplProbeBackoff = time.Millisecond
t.Cleanup(func() { reset(); tplProbeBackoff = old })
}
// fakeTpl : un llama-server réduit à /health, /props et /apply-template, avec
// un gabarit façon ChatML écrit à la main.
//
// qwen3 : le raisonnement n'est rendu qu'après le dernier message utilisateur ;
// keep : il est toujours rendu ;
// noagp : comme keep, mais add_generation_prompt est ignoré (vieux moteur) ;
// drop : reasoning_content n'est jamais rendu.
type fakeTpl struct {
mu sync.Mutex
mode string
key string // Bearer exigé hors /health
applyStatus []int // statuts imposés aux prochains /apply-template (0 = rendu)
applyBody string
paths []string
applies []map[string]any
}
var reCallID = regexp.MustCompile(`^[A-Za-z0-9]{9}$`)
func (f *fakeTpl) render(body map[string]any) (string, int) {
msgs, _ := body["messages"].([]any)
lastUser := -1
for i, m := range msgs {
if m.(map[string]any)["role"] == "user" {
lastUser = i
}
}
var b strings.Builder
if tools, ok := body["tools"].([]any); ok {
fmt.Fprintf(&b, "<tools n=%d>\n", len(tools))
}
for i, raw := range msgs {
m := raw.(map[string]any)
role, _ := m["role"].(string)
b.WriteString("<|im_start|>" + role + "\n")
if r, _ := m["reasoning_content"].(string); r != "" && role == "assistant" && f.mode != "drop" && (f.mode != "qwen3" || i > lastUser) {
b.WriteString("<think>" + r + "</think>")
}
c, _ := m["content"].(string)
b.WriteString(c)
calls, _ := m["tool_calls"].([]any)
for _, rc := range calls {
call := rc.(map[string]any)
id, _ := call["id"].(string)
// Gabarit strict : identifiant de 9 alphanumériques, résultat qui suit.
if !reCallID.MatchString(id) || i+1 >= len(msgs) || msgs[i+1].(map[string]any)["tool_call_id"] != id {
return `{"error":{"code":500,"message":"Jinja Exception: tool call id invalide"}}`, 500
}
fn := call["function"].(map[string]any)
b.WriteString("<tool_call>" + fn["name"].(string) + "</tool_call>")
}
b.WriteString("<|im_end|>\n")
}
if agp, _ := body["add_generation_prompt"].(bool); agp || f.mode == "noagp" {
b.WriteString("<|im_start|>assistant\n")
}
out, _ := json.Marshal(map[string]string{"prompt": b.String()})
return string(out), 200
}
func (f *fakeTpl) handler(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.paths = append(f.paths, r.URL.Path)
if r.URL.Path == "/health" {
_, _ = w.Write([]byte(`{"status":"ok"}`))
return
}
if f.key != "" && r.Header.Get("Authorization") != "Bearer "+f.key {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"code":401,"message":"Invalid API Key"}}`))
return
}
switch r.URL.Path {
case "/props":
fmt.Fprintf(w, `{"build_info":"b7000-abc","model_path":"/models/Qwen3-8B-Q8_0.gguf","chat_template":%q}`, f.mode)
case "/apply-template":
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
f.applies = append(f.applies, body)
if len(f.applyStatus) > 0 {
s := f.applyStatus[0]
f.applyStatus = f.applyStatus[1:]
if s != 0 {
w.WriteHeader(s)
_, _ = w.Write([]byte(f.applyBody))
return
}
}
out, status := f.render(body)
w.WriteHeader(status)
_, _ = w.Write([]byte(out))
default:
w.WriteHeader(http.StatusNotFound)
}
}
func (f *fakeTpl) start(t *testing.T) {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(f.handler))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
}
func (f *fakeTpl) count(path string) int {
f.mu.Lock()
defer f.mu.Unlock()
n := 0
for _, p := range f.paths {
if p == path {
n++
}
}
return n
}
// Jamais de complétion : avec un seul slot, elle évincerait le cache de la
// conversation que la sonde est censée servir.
func (f *fakeTpl) assertNoCompletion(t *testing.T) {
t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
for _, p := range f.paths {
if strings.Contains(p, "completion") {
t.Fatalf("la sonde a appelé %s", p)
}
}
}
func probeTools() []Tool {
return []Tool{{Type: "function", Function: ToolFunction{Name: "bash", Description: "d", Parameters: map[string]any{"type": "object"}}}}
}
func TestSondeGabaritQwen3(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "qwen3"}
f.start(t)
kw := map[string]any{"enable_thinking": false}
r := tplProbeEnsure(context.Background(), newTplShape(probeTools(), kw, ""))
if r.PreservesHistory != tplNo || r.PrefixStable != tplNo || r.RendersReasoning != tplYes {
t.Fatalf("Qwen3 retire le raisonnement passé : %+v", r)
}
if r.Build != "b7000-abc" || r.Model != "Qwen3-8B-Q8_0.gguf" || r.Tools != 1 || !r.cacheable {
t.Fatalf("métadonnées : %+v", r)
}
f.assertNoCompletion(t)
// Mêmes outils et chat_template_kwargs que la complétion ; jamais
// d'amorce de réponse, sauf le rendu qui vérifie que le moteur l'honore.
gen := 0
for _, a := range f.applies {
if tools, _ := a["tools"].([]any); len(tools) != 1 {
t.Fatalf("outils non transmis : %v", a["tools"])
}
if k, _ := a["chat_template_kwargs"].(map[string]any); k["enable_thinking"] != false {
t.Fatalf("chat_template_kwargs : %v", a["chat_template_kwargs"])
}
if a["add_generation_prompt"] == true {
gen++
}
}
if len(f.applies) != 4 || gen != 1 {
t.Fatalf("%d rendus dont %d avec amorce", len(f.applies), gen)
}
// Même moteur, même modèle, même gabarit, même forme : réponse en cache.
tplProbeEnsure(context.Background(), newTplShape(probeTools(), kw, ""))
if n := f.count("/apply-template"); n != 4 {
t.Fatalf("cache ignoré : %d rendus", n)
}
// Autre forme (raisonnement non coupé) : nouvelle sonde.
tplProbeEnsure(context.Background(), newTplShape(probeTools(), nil, ""))
if n := f.count("/apply-template"); n != 8 {
t.Fatalf("forme différente non resondée : %d rendus", n)
}
// Exposé dans /api/perf/summary.
rec := httptest.NewRecorder()
handlePerfSummary(rec, httptest.NewRequest(http.MethodGet, "/api/perf/summary", nil))
var sum struct {
Template *struct {
PreservesHistory string `json:"preserves_history"`
PrefixStable string `json:"prefix_stable"`
} `json:"template"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &sum); err != nil || sum.Template == nil || sum.Template.PrefixStable != "no" {
t.Fatalf("résumé : %s", rec.Body.String())
}
if strings.Contains(rec.Body.String(), "loki-sonde") {
t.Fatal("un rendu a fuité dans le résumé")
}
}
func TestSondeGabaritConserveLHistorique(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "keep"}
f.start(t)
r := tplProbeEnsure(context.Background(), newTplShape(nil, nil, ""))
if r.PreservesHistory != tplYes || r.PrefixStable != tplYes || r.RendersReasoning != tplYes {
t.Fatalf("gabarit qui garde tout : %+v", r)
}
for _, a := range f.applies {
if _, ok := a["tools"]; ok {
t.Fatal("outils envoyés alors que la complétion n'en avait pas")
}
}
}
// Gabarit (ou moteur) qui ignore reasoning_content partout : « jamais rendu »,
// ce qui suffit à REASONING_ECHO pour ne rien renvoyer d'inutile.
func TestSondeGabaritRaisonnementJamaisRendu(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "drop"}
f.start(t)
r := tplProbeEnsure(context.Background(), newTplShape(probeTools(), nil, ""))
if r.RendersReasoning != tplNo || r.PreservesHistory != tplNo || !strings.Contains(r.Note, "jamais rendu") {
t.Fatalf("raisonnement jamais rendu : %+v", r)
}
if !strings.Contains(tplProbeLine(r), "raisonnement_rendu=no") {
t.Fatalf("journal : %s", tplProbeLine(r))
}
}
// Un moteur qui ignore add_generation_prompt ne doit pas faire conclure
// « instable » : l'amorce rendue dans les deux cas fausserait la comparaison.
func TestSondeGabaritAmorceIgnoree(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "noagp"}
f.start(t)
r := tplProbeEnsure(context.Background(), newTplShape(nil, nil, ""))
if r.PrefixStable != tplUnknown || r.PreservesHistory != tplYes {
t.Fatalf("amorce ignorée : %+v", r)
}
}
// Moteur ancien ou fork sans la route : inconnu, rangé (ça ne changera pas
// avant un autre binaire), et aucun repli sur une complétion.
func TestSondeGabarit404(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "qwen3", applyStatus: []int{404, 404, 404, 404}, applyBody: "Not Found"}
f.start(t)
r := tplProbeEnsure(context.Background(), newTplShape(probeTools(), nil, ""))
if r.PreservesHistory != tplUnknown || r.PrefixStable != tplUnknown || r.RendersReasoning != tplUnknown || !r.cacheable {
t.Fatalf("404 : %+v", r)
}
f.assertNoCompletion(t)
tplProbeEnsure(context.Background(), newTplShape(probeTools(), nil, ""))
if n := f.count("/apply-template"); n != 2 {
t.Fatalf("404 resondé : %d rendus", n)
}
}
// Gabarit qui lève une exception (raise_exception) : inconnu, jamais oui/non.
func TestSondeGabaritExceptionDuGabarit(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "qwen3", applyStatus: []int{500, 500, 500, 500},
applyBody: `{"error":{"code":500,"message":"Jinja Exception: Conversation roles must alternate"}}`}
f.start(t)
r := tplProbeEnsure(context.Background(), newTplShape(probeTools(), nil, ""))
if r.PreservesHistory != tplUnknown || r.PrefixStable != tplUnknown || !strings.Contains(r.Note, "500") {
t.Fatalf("exception du gabarit : %+v", r)
}
f.assertNoCompletion(t)
}
// Moteur protégé par API_KEY : la sonde s'authentifie comme les autres appels
// internes. Sans clé, inconnu — et pas rangé : la clé peut arriver.
func TestSondeGabarit401(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "keep", key: "sk-loki-test"}
f.start(t)
r := tplProbeEnsure(context.Background(), newTplShape(nil, nil, ""))
if r.PreservesHistory != tplUnknown || r.PrefixStable != tplUnknown || !strings.Contains(r.Note, "401") {
t.Fatalf("401 : %+v", r)
}
if err := writeAPIKey("sk-loki-test"); err != nil {
t.Fatal(err)
}
r = tplProbeEnsure(context.Background(), newTplShape(nil, nil, ""))
if r.PreservesHistory != tplYes || r.PrefixStable != tplYes {
t.Fatalf("avec la clé : %+v", r)
}
}
// Modèle en chargement : les 503 sont relancés, sans rien conclure entre-temps.
func TestSondeGabarit503Relance(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "qwen3", applyStatus: []int{503, 503}, applyBody: `{"error":{"code":503,"message":"Loading model"}}`}
f.start(t)
r := tplProbeEnsure(context.Background(), newTplShape(nil, nil, ""))
if r.PreservesHistory != tplNo || r.PrefixStable != tplNo {
t.Fatalf("après 503 : %+v", r)
}
// 503 persistant : inconnu, non rangé.
freshTplProbe(t)
f.mu.Lock()
f.applyStatus = []int{503, 503, 503, 503, 503, 503, 503, 503, 503, 503, 503, 503}
f.mu.Unlock()
r = tplProbeEnsure(context.Background(), newTplShape(nil, nil, ""))
if r.PreservesHistory != tplUnknown || r.cacheable {
t.Fatalf("503 persistant : %+v", r)
}
}
// Preset externe : aucune requête, nulle part, et rien d'affiché.
func TestSondeGabaritPresetExterne(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "keep"}
srv := httptest.NewServer(http.HandlerFunc(f.handler))
t.Cleanup(srv.Close)
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "gpt-4o-mini", "", "", false))); err != nil {
t.Fatal(err)
}
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
r := tplProbeEnsure(context.Background(), newTplShape(nil, nil, ""))
if r.PreservesHistory != tplUnknown || r.PrefixStable != tplUnknown {
t.Fatalf("preset externe : %+v", r)
}
if n := len(f.paths); n != 0 {
t.Fatalf("preset externe sondé : %v", f.paths)
}
if _, ok := tplCapsCurrent(); ok {
t.Fatal("verdict affiché pour un preset externe")
}
}
// Bout en bout : une complétion du fil principal déclenche la sonde en tâche
// de fond, avec les outils et kwargs de CETTE requête, sans rien ajouter à la
// requête de complétion.
func TestSondeGabaritDeclencheeParRunChat(t *testing.T) {
testHome(t)
freshTplProbe(t)
freshPerf(t)
tplProbeAuto = true
t.Cleanup(func() { tplProbeAuto = false })
if err := SetConfigKey("REASONING", "off"); err != nil {
t.Fatal(err)
}
f := &fakeTpl{mode: "qwen3"}
var mu sync.Mutex
var chat map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/chat/completions" {
mu.Lock()
_ = json.NewDecoder(r.Body).Decode(&chat)
mu.Unlock()
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(sseChunk("ok") +
`data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":50,"completion_tokens":1},"timings":{"prompt_n":50,"cache_n":0}}` + "\n\n" +
"data: [DONE]\n\n"))
return
}
f.handler(w, r)
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
tools := probeTools()
if _, err := runChatTools(context.Background(), []Message{{Role: "user", Content: "salut"}}, tools, 0.7, Caps{}, func(StreamEvent) bool { return true }); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(5 * time.Second)
var r tplProbeResult
for {
var ok bool
if r, ok = tplCapsCurrent(); ok || time.Now().After(deadline) {
break
}
time.Sleep(10 * time.Millisecond)
}
if r.PrefixStable != tplNo || r.Tools != 1 || r.Kwargs["enable_thinking"] != false {
t.Fatalf("sonde après complétion : %+v", r)
}
f.mu.Lock()
for _, a := range f.applies {
mu.Lock()
same := fmt.Sprint(a["tools"]) == fmt.Sprint(chat["tools"]) && fmt.Sprint(a["chat_template_kwargs"]) == fmt.Sprint(chat["chat_template_kwargs"])
mu.Unlock()
if !same {
f.mu.Unlock()
t.Fatalf("forme différente de la complétion :\nsonde %v / %v\nchat %v / %v", a["tools"], a["chat_template_kwargs"], chat["tools"], chat["chat_template_kwargs"])
}
}
f.mu.Unlock()
// La sonde ne laisse aucune trace dans la requête de complétion.
mu.Lock()
defer mu.Unlock()
for k := range chat {
if strings.Contains(k, "template") && k != "chat_template_kwargs" || k == "add_generation_prompt" {
t.Fatalf("clé %q ajoutée à la complétion", k)
}
}
}
+23
View File
@@ -83,6 +83,12 @@ func toolBashBg(args map[string]any) string {
if reason := dangerousCommand(command); reason != "" {
return refusedCommandResult(reason)
}
// Un serveur de dev ou un build lancé pendant une optimisation fausserait
// ses mesures (CPU, disque) — et un tour de modèle n'a de toute façon pas pu
// démarrer : ceci ne vise qu'un tour lancé juste avant.
if err := tuneGuard(); err != nil {
return "[erreur] " + err.Error()
}
ws := agentCwd()
logDir := filepath.Join(ws, ".loki", "jobs")
if err := os.MkdirAll(logDir, 0o755); err != nil {
@@ -171,6 +177,23 @@ func toolBashTail(args map[string]any) string {
return out + "\n" + tailRunes(tail, toolMaxOutput)
}
// bgJobsRunning : jobs d'arrière-plan encore en vie. L'optimiseur refuse de
// mesurer à côté d'eux : un build ou un serveur de dev prend le CPU et le
// disque que mesurent ses essais.
func bgJobsRunning() int {
jobsMu.Lock()
defer jobsMu.Unlock()
n := 0
for _, j := range jobs {
j.mu.Lock()
if !j.done {
n++
}
j.mu.Unlock()
}
return n
}
// stopConvJobs tue les jobs d'une discussion (suppression / vidage de la
// discussion : ses fichiers partent, ses process aussi).
func stopConvJobs(convID string) {
+14
View File
@@ -42,3 +42,17 @@ func wantsPlan(text string) bool {
}
return false
}
// withTurnRole fige le rôle d'un tour du mode Code d'après son message : plan
// demandé → planner, sinon builder. Un rôle déjà choisi par le client est gardé.
// Partagé par StartTurn et le préchauffage (chat_prewarm.go), qui prévoit le
// rôle le plus probable du prochain message.
func withTurnRole(caps Caps, text string) Caps {
if caps.Code && caps.Role == "" {
caps.Role = "builder"
if wantsPlan(text) {
caps.Role = "planner"
}
}
return caps
}
+50 -14
View File
@@ -156,21 +156,38 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
// Même contexte que le tour de build : projet (description, index mémoire,
// trackers) et consignes du dépôt. Sans eux, la reprise changeait le début
// du premier message utilisateur — tout le cache de prompt à refaire.
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...)
// du premier message utilisateur — tout le cache de prompt à refaire. Même
// assemblage aussi (turnView) : avec PROJ_SNAPSHOT, le bloc figé de la
// discussion, et ses changements en tête de la consigne.
sent, tools, snapTurn := c.turnView(caps, epoch, msgs, true)
runCtx := ctx
if snapTurn != nil {
runCtx = withProjSnapTurn(ctx, snapTurn)
}
var content strings.Builder
sawUsage := false
sent, tools := prepareTurn(final, caps)
extra, _ := runChatTools(ctx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
// newBase / echo : comme generate — vue modèle publiée par une compaction
// survenue pendant la passe (elle REMPLACE l'historique, tour compris), et
// raisonnement de la réponse finale (REASONING_ECHO).
var newBase []Message
var echo *ReasoningEcho
extra, _ := runChatTools(runCtx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
if ev.Stats != nil && ev.Stats.PromptTokensTotal > 0 {
sawUsage = true
}
if ev.NewHistory != nil {
// Préfixe système injecté retiré, comme dans generate : il n'appartient
// pas à l'historique persisté. Ignorer cet événement laissait la
// compaction perdue et c.Messages incohérent (fil complet + tour).
base := ev.NewHistory
for len(base) > 0 && base[0].Role == "system" {
base = base[1:]
}
newBase = append([]Message(nil), base...)
}
if ev.Echo != nil {
echo = ev.Echo
}
if ev.Content != "" {
content.WriteString(ev.Content)
}
@@ -185,9 +202,15 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
})
c.mu.Lock()
if c.epoch == epoch {
if newBase != nil {
c.Messages = newBase
}
if s := snapTurn.takeRefreshed(); s != nil {
c.ProjSnap = s // même règle que generate
}
c.Messages = append(c.Messages, extra...)
if s := content.String(); strings.TrimSpace(s) != "" {
c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
c.Messages = append(c.Messages, withEcho(Message{Role: "assistant", Content: s}, echo))
}
c.Messages = dropStrayNudges(c.Messages) // même règle que generate
if sawUsage {
@@ -202,8 +225,10 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
}
// forwardStream relaie les événements d'un runChat secondaire (vérification,
// correction) vers le journal d'affichage — même mapping que generate(), sans
// la gestion de compaction (ces passes n'en déclenchent pas : contexte court).
// correction) vers le journal d'affichage — même mapping que generate(). Ce
// qui touche à l'HISTORIQUE (NewHistory, Echo) n'a rien à afficher : c'est
// l'appelant qui le range (runBuilderTurn), ou l'ignore pour une passe isolée
// sur sa propre trace (vérificateur).
//
// isolated : la passe tourne sur sa propre trace (vérificateur), PAS sur
// l'historique de la discussion. Ses comptes ne disent rien du contexte de
@@ -212,6 +237,14 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
// chiffre-là. La jauge de l'UI ne doit pas bouger non plus (ctx_isolated).
func (c *Conversation) forwardStream(ev StreamEvent, epoch int, isolated bool) {
switch {
case ev.NewHistory != nil, ev.Echo != nil:
// Rangés par l'appelant, voir plus haut.
case ev.Compacting != nil:
// Même bannière que generate. Pas pour une passe isolée : ce n'est pas
// la discussion qu'elle compacte.
if !isolated {
c.appendDelta(epoch, map[string]any{"compacting": *ev.Compacting})
}
case ev.Err != nil:
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
case ev.ToolUsed != nil:
@@ -269,7 +302,9 @@ func (c *Conversation) forwardStream(ev StreamEvent, epoch int, isolated bool) {
}
}
// lastUserText retrouve le dernier message utilisateur du fil (la tâche).
// lastUserText retrouve le dernier message utilisateur du fil (la tâche), sans
// la mise à jour du projet que Loki a pu poser en tête (PROJ_SNAPSHOT) : le
// vérificateur la prendrait pour une partie de la demande.
func (c *Conversation) lastUserText() string {
c.mu.Lock()
defer c.mu.Unlock()
@@ -278,7 +313,8 @@ func (c *Conversation) lastUserText() string {
if c.Messages[i].Role != "user" || isLokiInjected(c.Messages[i]) {
continue
}
if s, ok := c.Messages[i].Content.(string); ok {
m, _ := withoutCtxUpdate(c.Messages[i])
if s, ok := m.Content.(string); ok {
return s
}
}
+24 -1
View File
@@ -159,6 +159,9 @@ type benchOpts struct {
Mode string
Prompt int // jetons de la ligne courte
Predict int // jetons générés par la ligne courte
// Depth : profondeur imposée — l'optimiseur (backend_tune_run.go) compare
// ses essais à la MÊME profondeur ; 0 = choisie selon le contexte.
Depth int
}
func (o benchOpts) full() bool { return o.Mode == benchModeFull }
@@ -228,7 +231,14 @@ func (e benchEngine) do(ctx context.Context, method, path string, payload any) (
}
// Requête en vol jusqu'à la lecture complète du corps : l'isolation des
// travaux annexes n'efface jamais le slot pendant ce temps (llm_slots.go).
defer engineRequestStart()()
// Une requête du second slot (SIDE_SLOT) ne touche pas au slot de la
// discussion.
side := false
if m, ok := payload.(map[string]any); ok {
side = payloadOnSideSlot(m)
}
end, _ := engineRequestBeginSide(nil, side)
defer end()
resp, err := e.client.Do(req)
if err != nil {
return nil, err
@@ -422,6 +432,9 @@ type benchSetup struct {
kv string
kvQuant bool
gpus string
// sideSlot : SIDE_SLOT en service, le bench passe par le second slot
// (id_slot 1) et laisse la conversation en place (llm_sideslot.go).
sideSlot bool
}
func benchSetupFrom(cfg map[string]string, argEnv map[string]string) benchSetup {
@@ -492,6 +505,9 @@ func (s benchSetup) payload(msgs []Message, maxTokens int, cache bool) map[strin
if s.kwargs != nil {
p["chat_template_kwargs"] = s.kwargs
}
if s.sideSlot {
setEngineSlot(p, 1)
}
return p
}
@@ -662,6 +678,9 @@ func benchRun(ctx context.Context, e benchEngine, opts benchOpts, s benchSetup,
// 3. La profondeur.
d, why := benchDepthFor(nCtx, s.cpuPlaced, warm)
if opts.Depth > 0 {
d, why = opts.Depth, ""
}
depth := &benchDepth{Ctx: nCtx, Target: d, Reuse: -1}
res.Depth = depth
if why != "" {
@@ -809,6 +828,9 @@ func runBench(ctx context.Context, opts benchOpts, progress benchProgress) (*ben
if externalActive() {
return nil, fmt.Errorf("benchmark indisponible : le preset actif est une API externe")
}
if err := tuneGuard(); err != nil {
return nil, err
}
port := LLMPort()
if !healthCheck() {
return nil, fmt.Errorf("serveur injoignable sur :%d", port)
@@ -825,6 +847,7 @@ func runBench(ctx context.Context, opts benchOpts, progress benchProgress) (*ben
effortRemember(want, got)
logEffortFallback(want, got)
}
setup.sideSlot = engineSlotFor(resolveChatEndpoint(), false) == 1
eng := benchEngine{base: fmt.Sprintf("http://localhost:%d", port), auth: resolveChatEndpoint().auth, client: http.DefaultClient}
// Le bench prend le slot de la conversation : une fois fini (erreur et
// annulation comprises), on l'efface si c'est sans risque, pour que son état
+12 -2
View File
@@ -38,18 +38,25 @@ var errBenchBusy = errors.New("un benchmark occupe le moteur — attends sa fin
// qui a pu démarrer depuis ne doit pas être déclaré libre. L'epoch ne suffit
// pas : changer de discussion le bumpe SANS arrêter le bench (loadFrom).
func (c *Conversation) benchLease(cancel context.CancelFunc) (func(), error) {
return c.measureLease(cancel, errBenchBusy)
}
// measureLease : le verrou de génération pour une mesure du moteur — bench,
// ou optimiseur (backend_tune_job.go) — why étant le refus que reçoivent les
// autres pendant ce temps.
func (c *Conversation) measureLease(cancel context.CancelFunc, why error) (func(), error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.Generating {
return nil, ErrBusy
}
c.Generating, c.benching, c.cancel = true, true, cancel
c.Generating, c.benching, c.cancel, c.leaseWhy = true, true, cancel, why
var once sync.Once
return func() {
once.Do(func() {
c.mu.Lock()
if c.benching {
c.Generating, c.benching, c.cancel = false, false, nil
c.Generating, c.benching, c.cancel, c.leaseWhy = false, false, nil, nil
}
c.mu.Unlock()
})
@@ -92,6 +99,9 @@ func benchStart(opts benchOpts) (id string, code int, err error) {
if externalActive() {
return "", http.StatusBadRequest, errors.New("benchmark indisponible : le preset actif est une API externe")
}
if err := tuneGuard(); err != nil {
return "", http.StatusConflict, err
}
if !healthCheck() {
return "", http.StatusServiceUnavailable, errModelLoading
}
+387 -74
View File
@@ -25,6 +25,29 @@ type Message struct {
Content any `json:"content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
// ReasoningContent : raisonnement que le moteur a séparé pour ce message
// assistant, gardé seulement avec REASONING_ECHO=on (llm_reasoning_echo.go).
// ReasoningModel : le modèle qui l'a produit. Les deux sont persistés avec la
// discussion ; à l'envoi, echoMessages retire toujours l'étiquette, et le
// raisonnement quand il ne doit pas partir. Vides : JSON inchangé.
ReasoningContent string `json:"reasoning_content,omitempty"`
ReasoningModel string `json:"reasoning_model,omitempty"`
// ImgRelay : message `user` qui relaie l'image d'un outil (capture,
// see_image) sous KEEP_TURN_IMAGES (chat_keep_images.go) — jamais posé sans
// la clé. Persisté pour qu'un fil rouvert le reconnaisse encore comme un
// relais et non comme une demande (isLokiInjected) ; retiré à l'envoi
// (wireMessages). Faux : JSON inchangé.
ImgRelay bool `json:"img_relay,omitempty"`
// CtxUpd : Loki a posé un bloc <context_update> en tête de ce message
// utilisateur (PROJ_SNAPSHOT, chat_projsnap.go) — jamais sans la clé. Seuls
// les messages marqués sont nettoyés : un texte TAPÉ qui commencerait par la
// même balise reste tel quel. Persisté, retiré à l'envoi (wireMessages).
CtxUpd bool `json:"ctx_upd,omitempty"`
// imgTokens : coût mesuré par le moteur de l'image d'un relais GARDÉ dans
// l'historique (KEEP_TURN_IMAGES) ; 0 = image non gardée, ou déjà retirée.
// En mémoire seulement : un fil rechargé a de toute façon perdu ses images
// (stripImageParts).
imgTokens int
}
type ToolCall struct {
@@ -506,8 +529,8 @@ func EnabledTools(caps Caps) []Tool {
}
// StreamEvent is what a ChatCallback receives for each piece of streamed output.
// Exactly one of {Content, Reasoning, ToolUsed, Stats, Err, DropReasoning} is set
// per call.
// Exactly one of {Content, Reasoning, ToolUsed, Stats, Err, DropReasoning, Echo}
// is set per call.
type StreamEvent struct {
Content string
Reasoning string
@@ -532,6 +555,11 @@ type StreamEvent struct {
// REMPLACER son historique par elle (préfixe système injecté retiré), pas l'y
// ajouter.
NewHistory []Message
// Echo : raisonnement séparé de la complétion qui porte la réponse finale,
// à ranger avec elle (withEcho). Émis une fois, en fin de tour, et seulement
// avec REASONING_ECHO=on sur le moteur local : les autres tours n'en voient
// jamais. Les messages tool_calls du tour portent déjà le leur (extra).
Echo *ReasoningEcho
}
// AskEvent : l'outil ask — question structurée posée à l'utilisateur, rendue
@@ -902,12 +930,15 @@ type StatsEvent struct {
// `usage.prompt_tokens`. 0 si le backend ne renvoie pas d'usage.
PromptTokensTotal int `json:"prompt_tokens_total,omitempty"`
// Morceaux de raisonnement reçus SÉPARÉS par le moteur (reasoning_content),
// llama-server local seulement. Loki ne renvoie jamais ce raisonnement au
// modèle (Message n'a pas de champ pour lui) : ces jetons font partie de
// GenTokens mais pas de la requête suivante. Un morceau vaut au plus un
// jeton, donc ce compte ne peut que sous-estimer — le sens sans danger : on
// compacte au pire comme avant, jamais trop tard. Voir ctxAfter.
// llama-server local seulement. Sans REASONING_ECHO, Loki ne renvoie jamais
// ce raisonnement au modèle : ces jetons font partie de GenTokens mais pas de
// la requête suivante. Un morceau vaut au plus un jeton, donc ce compte ne
// peut que sous-estimer — le sens sans danger : on compacte au pire comme
// avant, jamais trop tard. Voir ctxAfter.
ReasoningTokens int `json:"reasoning_tokens,omitempty"`
// echoed : ce raisonnement-là repart dans la requête suivante
// (REASONING_ECHO) — ctxAfter ne le retire alors pas. Jamais sérialisé.
echoed bool
// Conseil ponctuel quand le cache de prompts n'a pas tenu après un travail
// annexe (voir noteEnginePrompt). Vide la plupart du temps.
CacheHint string `json:"cache_hint,omitempty"`
@@ -932,7 +963,12 @@ type StatsEvent struct {
// raisonnement, jamais renvoyé. Compter ce raisonnement gonflait le contexte de
// 1 à 8 k jetons par étape, et la compaction (avec perte) partait trop tôt.
// Borné par GenTokens : le compte de morceaux ne retire jamais plus que généré.
// Raisonnement renvoyé (echoed) : il fait partie de la requête suivante, il
// reste compté.
func (s StatsEvent) ctxAfter() int {
if s.echoed {
return s.PromptTokensTotal + s.GenTokens
}
return s.PromptTokensTotal + s.GenTokens - min(s.GenTokens, s.ReasoningTokens)
}
@@ -1097,6 +1133,112 @@ func toolCallLabel(name string, args map[string]any) string {
return label
}
// turnReasoning : ce que la requête dit du raisonnement, tiré de la
// configuration. Partagé par runChat et le préchauffage (chat_prewarm.go).
func turnReasoning(cfg map[string]string) (effortWanted, effort string, thinkOff bool, kwargs map[string]any) {
// Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide
// = on n'envoie rien. Réglage par preset, donc de fait par modèle.
effortWanted = reasoningEffortValue(cfg["REASONING_EFFORT"])
// …sauf si le gabarit de CE modèle a déjà refusé ce niveau (llm_effort.go) :
// on part alors directement sur la traduction apprise, sans repayer le 500.
effort = effortResolve(effortWanted)
// Le raisonnement est-il interdit pour ce tour ? « aucune » compte comme une
// interdiction, y compris quand le repli ci-dessus a retiré le niveau : c'est
// `enable_thinking` qui porte alors la consigne, seul.
thinkOff = reasoningExplicitlyOff(cfg["REASONING"]) || effortWanted == "none"
// Ce que le gabarit du modèle doit savoir, dans SA langue : `reasoning_effort`
// ne parle qu'aux gabarits qui le lisent, `enable_thinking` parle aux modèles
// hybrides (Qwen3 & co). Sans ça, « aucune » n'avait aucun effet sur eux : le
// modèle réfléchissait pendant que l'interface annonçait le contraire.
kwargs = reasoningTemplateKwargs(thinkOff, effort)
return effortWanted, effort, thinkOff, kwargs
}
// turnEchoPolicy : la politique de REASONING_ECHO d'une requête ; off = preset
// externe ou repli en cours, rien ne repart.
func turnEchoPolicy(off bool) echoPolicy {
if off {
return echoPolicy{}
}
return currentEchoPolicy()
}
// wireMessages : les messages tels qu'ils partent au moteur — un seul système
// en tête, la consigne de fin si les outils sont neutralisés, le raisonnement
// renvoyé selon pol — et le nombre de raisonnements renvoyés.
func wireMessages(messages []Message, toolChoiceNone bool, pol echoPolicy) ([]Message, int) {
sent := normalizeSystemMessages(messages)
// Marque de relais d'image (KEEP_TURN_IMAGES) : propre à Loki, jamais au
// moteur. Sans la clé, aucune : la tranche repart telle quelle.
sent = stripRelayTags(sent)
if toolChoiceNone {
sent = withTrailingHint(sent, toolsOffHint)
}
return echoMessages(sent, pol)
}
// chatPayloadOpts : ce qui, en plus des messages, façonne une requête de chat.
type chatPayloadOpts struct {
tools []Tool
disableTools bool
toolChoiceNone bool
effort string
kwargs map[string]any
// noSampling : sans l'échantillonnage du preset. Pour le résumé en
// continuation (COMPACT_CONTINUATION, chat_compact_cont.go), qui garde celui
// du résumeur d'aujourd'hui — température 0.2 seule : une pénalité de
// répétition jouerait contre la reprise fidèle des noms, chemins et chiffres.
// L'échantillonnage ne touche pas au prompt : le cache n'y perd rien.
noSampling bool
}
// buildChatPayload : le corps d'une requête de complétion de chat, à partir des
// messages déjà passés par wireMessages. UN seul endroit le construit : le
// préchauffage (chat_prewarm.go) envoie ce même corps, aux seuls champs de
// réponse près (stream, max_tokens), et ne peut donc pas en dériver.
func buildChatPayload(ep chatEndpoint, sent []Message, temperature float64, o chatPayloadOpts) map[string]any {
payload := map[string]any{
"model": ep.Model,
// Les images de l'historique y sont rangées par référence
// (chat_images.go) : on remet leurs octets juste avant l'envoi.
"messages": expandImageRefs(sent),
"stream": true,
"temperature": temperature,
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
// du prompt (préfixe caché compris), contrairement à timings.prompt_n qui
// ne compte que les tokens nouvellement traités. Sert au comptage exact du
// contexte (sinon le system prompt déjà en cache n'est pas recompté).
"stream_options": map[string]any{"include_usage": true},
}
// Échantillonnage du preset (top_p/top_k/min_p/pénalités, et TEMP qui
// l'emporte sur la température ci-dessus). Posé AVANT le raisonnement : les
// deux blocs écrivent des clés disjointes, mais l'ordre rend explicite que
// c'est bien loki qui a le dernier mot sur `chat_template_kwargs`.
if !o.noSampling {
applySampling(payload)
}
if o.effort != "" {
payload["reasoning_effort"] = o.effort
}
// chat_template_kwargs est propre à llama.cpp (--jinja) : une API
// distante stricte (OpenAI) refuse un argument inconnu (400).
if o.kwargs != nil && !ep.External {
payload["chat_template_kwargs"] = o.kwargs
}
if len(o.tools) > 0 && !o.disableTools {
payload["tools"] = o.tools
// The model sometimes emits parallel tool calls, which this llama.cpp
// build serialises as two concatenated JSON objects in one arguments
// string ("{...}{...}") and then fails to parse (HTTP 500). Forcing a
// single tool call per turn avoids that.
payload["parallel_tool_calls"] = false
if o.toolChoiceNone {
payload["tool_choice"] = "none"
}
}
return payload
}
// injectQueued (optionnel, variadique pour ne pas toucher aux appels hors chat)
// est consulté à CHAQUE frontière d'étape de la boucle d'outils : il renvoie les
// messages utilisateur mis en file PENDANT la génération, pour que le modèle les
@@ -1109,7 +1251,41 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// runChatTools est runChat avec les outils déjà calculés par prepareTurn : ceux
// que décrit le préambule, à l'octet près.
func runChatTools(ctx context.Context, messages []Message, tools []Tool, temperature float64, caps Caps, cb ChatCallback, injectQueued ...func() []Message) ([]Message, error) {
// KEEP_TURN_IMAGES (chat_keep_images.go) : nil sans la clé, et tout se
// passe alors comme avant. En fin de tour, une image gardée à l'essai mais
// jamais mesurée redevient éphémère, quelle que soit la sortie de la boucle.
kt := newKeepImages(ReadConfig(), perfTagOf(ctx))
extra, err := runChatLoop(ctx, kt, messages, tools, temperature, caps, cb, injectQueued...)
return kt.finish(extra), err
}
// runChatLoop : la boucle de runChatTools.
func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools []Tool, temperature float64, caps Caps, cb ChatCallback, injectQueued ...func() []Message) ([]Message, error) {
var extra []Message
// Bloc projet figé de la discussion (PROJ_SNAPSHOT, chat_projsnap.go) : nil
// hors tour de discussion avec la clé. Masqué pour la suite du contexte —
// un sous-agent lancé par un outil a son propre fil, pas ce bloc.
snapTurn := projSnapTurnFrom(ctx)
if snapTurn != nil {
ctx = withProjSnapTurn(ctx, nil)
}
// newHistory publie l'historique réécrit en cours de tour (compaction,
// réduction). Le début du prompt change de toute façon : avec la clé, le
// bloc figé y est remplacé par le vivant et les mises à jour retirées.
// publishHistory : la même publication, sans rafraîchir le bloc projet —
// pour un retrait d'images gardées (KEEP_TURN_IMAGES), qui ne touche pas au
// début du prompt. KEEP_TURN_IMAGES : une image non gardée n'entre pas dans
// l'historique par ce chemin non plus. Sans la clé, publication telle quelle.
publishHistory := func() {
kt.rewritten()
cb(StreamEvent{NewHistory: kt.publishable(append([]Message(nil), messages...))})
}
newHistory := func() {
if snapTurn != nil {
messages = snapTurn.refresh(messages)
}
publishHistory()
}
// Some backends (vanilla llama.cpp builds) don't populate `reasoning_content`
// in streaming mode: the model's <think> block (opened by the chat template)
// arrives inline in `content`, terminated by a literal </think>. When
@@ -1121,21 +1297,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
// (perf_log.go) : rien de tout ça ne part dans la requête.
ptag, perfIter := perfTagOf(ctx), 0
reasoningOn := reasoningActive(chatCfg["REASONING"])
// Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide
// = on n'envoie rien. Réglage par preset, donc de fait par modèle.
effortWanted := reasoningEffortValue(chatCfg["REASONING_EFFORT"])
// …sauf si le gabarit de CE modèle a déjà refusé ce niveau (llm_effort.go) :
// on part alors directement sur la traduction apprise, sans repayer le 500.
reasoningEffort := effortResolve(effortWanted)
// Le raisonnement est-il interdit pour ce tour ? « aucune » compte comme une
// interdiction, y compris quand le repli ci-dessus a retiré le niveau : c'est
// `enable_thinking` qui porte alors la consigne, seul.
thinkOff := reasoningExplicitlyOff(chatCfg["REASONING"]) || effortWanted == "none"
// Ce que le gabarit du modèle doit savoir, dans SA langue : `reasoning_effort`
// ne parle qu'aux gabarits qui le lisent, `enable_thinking` parle aux modèles
// hybrides (Qwen3 & co). Sans ça, « aucune » n'avait aucun effet sur eux : le
// modèle réfléchissait pendant que l'interface annonçait le contraire.
reasoningKwargs := reasoningTemplateKwargs(thinkOff, reasoningEffort)
effortWanted, reasoningEffort, thinkOff, reasoningKwargs := turnReasoning(chatCfg)
// When llama.cpp fails to parse a model-generated tool call (HTTP 500), we
// retry the same turn once with tools removed so the model answers in plain
// text from the tool results already gathered, instead of dying mid-chat.
@@ -1155,6 +1317,8 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
compactedRetry := false
// Réductions forcées (shrinkToFit) quand le compactage n'a pas suffi.
shrinkRetries := 0
// Rejeux à l'identique après un cache KV plein en plein calcul (SIDE_SLOT).
poolRetries := 0
// Repli d'intensité de raisonnement (llm_effort.go) : une seule tentative par
// tour, comme les autres filets.
effortRetried := false
@@ -1199,6 +1363,12 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
// entier.
const maxStreamRetries = 5
streamRetries := 0
// REASONING_ECHO (llm_reasoning_echo.go). echoOffTurn : plus aucun
// raisonnement ne part de ce tour (prompt trop long, ou gabarit qui l'a
// refusé) — retour exact à la requête d'avant la clé. echoTplRetry : relance
// en cours après une erreur de gabarit ; si elle passe, c'était bien le
// raisonnement, et le refus est retenu pour ce modèle.
echoOffTurn, echoTplRetry := false, false
// Destination des complétions : llama-server local, ou une API OpenAI-compatible
// externe si le preset actif en est un (backend_external.go). Résolu UNE FOIS
// par tour — une bascule de preset en plein tour est rare, et se rejoue de
@@ -1238,7 +1408,16 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" {
budgetNudges++
logBudget(toolRuns, budget, budgetNudges)
messages, extra = appendNudge(messages, extra, msg)
// NUDGE_IN_TOOL (opt-in, gabarits dont le rendu bouge quand un message
// user s'ajoute) : au bout du dernier résultat d'outil. Sinon, ou si
// la forme ne s'y prête pas, un message à part comme toujours.
placed := false
if nudgeInToolOn(chatCfg, ep) {
messages, extra, placed = nudgeIntoTool(messages, extra, msg)
}
if !placed {
messages, extra = appendNudge(messages, extra, msg)
}
}
// Appels d'outils exécutables pour cette complétion : outils annoncés,
// et ni coupés ni neutralisés par tool_choice « none ».
@@ -1247,52 +1426,26 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
// stricts (Qwen3.x) refusent un système ailleurs qu'en position 0.
// Gardé à part : la télémétrie compare ces messages-là d'une requête à
// l'autre (perfPrefix).
sent := normalizeSystemMessages(messages)
if toolChoiceNone {
sent = withTrailingHint(sent, toolsOffHint)
}
payload := map[string]any{
"model": ep.Model,
// Les images de l'historique y sont rangées par référence
// (chat_images.go) : on remet leurs octets juste avant l'envoi.
"messages": expandImageRefs(sent),
"stream": true,
"temperature": temperature,
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
// du prompt (préfixe caché compris), contrairement à timings.prompt_n qui
// ne compte que les tokens nouvellement traités. Sert au comptage exact du
// contexte (sinon le system prompt déjà en cache n'est pas recompté).
"stream_options": map[string]any{"include_usage": true},
}
// Échantillonnage du preset (top_p/top_k/min_p/pénalités, et TEMP qui
// l'emporte sur la température ci-dessus). Posé AVANT le raisonnement : les
// deux blocs écrivent des clés disjointes, mais l'ordre rend explicite que
// c'est bien loki qui a le dernier mot sur `chat_template_kwargs`.
applySampling(payload)
if reasoningEffort != "" {
payload["reasoning_effort"] = reasoningEffort
}
// chat_template_kwargs est propre à llama.cpp (--jinja) : une API
// distante stricte (OpenAI) refuse un argument inconnu (400).
if reasoningKwargs != nil && !ep.External {
payload["chat_template_kwargs"] = reasoningKwargs
}
if len(tools) > 0 && !disableTools {
payload["tools"] = tools
// The model sometimes emits parallel tool calls, which this llama.cpp
// build serialises as two concatenated JSON objects in one arguments
// string ("{...}{...}") and then fails to parse (HTTP 500). Forcing a
// single tool call per turn avoids that.
payload["parallel_tool_calls"] = false
if toolChoiceNone {
payload["tool_choice"] = "none"
}
}
// Raisonnement renvoyé : décidé au point de sortie unique, pour chaque
// requête. Clé absente (ou preset externe, ou repli en cours) :
// echoMessages rend la tranche telle quelle, requête inchangée.
// Assemblage partagé avec le préchauffage (chat_prewarm.go) : les deux ne
// peuvent pas diverger.
pol := turnEchoPolicy(ep.External || echoOffTurn)
sent, echoSent := wireMessages(messages, toolChoiceNone, pol)
payload := buildChatPayload(ep, sent, temperature, chatPayloadOpts{
tools: tools, disableTools: disableTools, toolChoiceNone: toolChoiceNone,
effort: reasoningEffort, kwargs: reasoningKwargs,
})
// SIDE_SLOT (llm_sideslot.go) : le fil de la discussion sur le slot 0,
// le reste sur le 1. Sans second slot en service, aucun id_slot.
setEngineSlot(payload, engineSlotFor(ep, slotIsMain(ptag.kind)))
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
if err != nil {
return extra, err
}
kt.sending(len(messages)) // KEEP_TURN_IMAGES : rien sans la clé
req.Header.Set("Content-Type", "application/json")
// ⚠️ Surtout pas authHeader : celui-ci pose la clé du serveur LOCAL, et
// l'envoyer à api.openai.com serait fuiter un secret chez un tiers en même
@@ -1302,9 +1455,18 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
// Requête en vol vers le moteur local, jusqu'à la lecture complète du corps :
// l'isolation des travaux annexes n'efface jamais le slot pendant ce temps
// (llm_slots.go). Rien à compter pour une API externe.
// Un préchauffage en vol (PREWARM, chat_prewarm.go) est annulé ici, sauf
// s'il prépare exactement le début de CETTE requête d'un tour de chat.
endReq := func() {}
var reqSeq uint64
// SLOT_PERSIST (llm_slotpersist.go) : l'état gardé à la dernière bascule
// de preset est rechargé avant la première requête de la discussion,
// si tout concorde. Sans la clé, rien.
if !ep.External && ptag.kind == perfMain {
slotPersistRestore(ep, ptag.conv)
}
if !ep.External {
endReq = engineRequestStart()
endReq, reqSeq = engineRequestBeginSide(prewarmKeeper(ptag.kind, sent, payload), payloadOnSideSlot(payload))
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
@@ -1336,6 +1498,9 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
if msg == "" {
msg = resp.Status
}
// La relance sans raisonnement est refusée à son tour : il n'y était
// pour rien, aucun refus à retenir pour ce modèle.
echoTplRetry = false
// Refus du niveau de raisonnement par le gabarit du modèle (Qwen3.8 ne
// connaît pas « high », gpt-oss ne connaît pas « xhigh »…). C'est un
// refus DÉFINITIF, pas une question de taille de prompt : à traiter
@@ -1352,12 +1517,66 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
continue
}
}
// SIDE_SLOT : un cache KV à court de place en plein calcul n'est pas un
// prompt trop long (llm_sideslot.go). Rejouée telle quelle, sans
// compaction ni réduction — deux fois au plus, le temps que l'autre
// slot finisse, puis l'erreur telle quelle : la conversation tenait
// dans sa fenêtre, rien ne justifie d'en perdre un morceau. Sans
// second slot en service, ce filet ne joue pas.
if !ep.External && sideSlotPoolError(msg) && sideSlotLive(ep) {
if poolRetries < 2 {
poolRetries++
logCtx("cache KV plein pendant le calcul (deux slots) : requête rejouée telle quelle, sans compaction")
if werr := llmNetBackoff(ctx, poolRetries-1); werr == nil {
continue
}
}
err := fmt.Errorf("llama-server a renvoyé %d : %s", resp.StatusCode, msg)
cb(StreamEvent{Err: err})
return extra, err
}
// Gabarit qui refuse le raisonnement renvoyé (gpt-oss : « Cannot pass
// both content and thinking », message assistant jugé invalide…) :
// rejoué UNE fois sans lui, AVANT la coupure des outils et la consigne
// système, qui casseraient le tour et tout le cache de prompt.
if echoSent > 0 && echoTemplateError(resp.StatusCode, msg) {
echoOffTurn, echoTplRetry = true, true
logCtx("erreur de gabarit avec le raisonnement renvoyé (%d) : relance sans lui", resp.StatusCode)
continue
}
// Prompt trop long alors que du raisonnement est parti : on retente
// d'abord sans lui — exactement la requête d'avant REASONING_ECHO —,
// avant toute compaction ou réduction, qui perdent de l'information.
if echoSent > 0 && contextOverflow(msg, messages) {
echoOffTurn = true
logCtx("prompt trop long avec le raisonnement renvoyé : relance sans lui avant toute compaction")
continue
}
// Le prompt a peut-être dépassé la fenêtre de contexte : on tente une
// compaction en vol et on rejoue le tour (une seule fois) avant tout le
// reste. C'est le filet de secours à la Hermes.
// ⚠️ Seulement si l'erreur est VRAIMENT un débordement de contexte : avant,
// n'importe quel refus (appel d'outil mal formé, modèle en chargement,
// erreur de template…) résumait ~75 % de la conversation, même courte.
// Raisonnement déjà retiré de ce tour (repli ci-dessus) : la réduction
// travaille sur ce qui part vraiment. Sinon elle compterait — puis
// « retirerait » — du raisonnement qui ne partait plus, et gâcherait
// une relance sur une requête identique.
if echoOffTurn {
messages = stripReasoning(messages)
}
// KEEP_TURN_IMAGES : les images gardées partent d'abord, et la requête
// est rejouée sans elles ; le résumé (avec perte) ne vient qu'ensuite,
// s'il le faut encore. Sans la clé, aucune image gardée.
if contextOverflow(msg, messages) && hasKeptImages(messages) {
if s, freed := dropKeptImages(messages); freed > 0 {
logKeptImagesDropped("réactif", freed)
messages = s
extra = nil
publishHistory()
continue
}
}
if compactEnabled() && !compactedRetry && contextOverflow(msg, messages) {
if c, changed := compactMessages(ctx, messages, caps); changed {
compactedRetry = true
@@ -1370,7 +1589,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
// secours ne survit pas à la fin du tour et le prompt re-déborde au
// message suivant.
extra = nil
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
newHistory()
continue
}
}
@@ -1383,7 +1602,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
logCompact("réduction", overflowTokens(msg), messages, c, changed)
messages = c
extra = nil
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
newHistory()
continue
}
}
@@ -1439,8 +1658,21 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
// Une réponse est arrivée : le budget de reprise réseau repart à neuf pour
// la suite de la boucle d'outils.
netRetries = 0
// La relance sans raisonnement passe : c'était bien lui que le gabarit
// refusait. Retenu pour ce modèle, plus de 500 à repayer à chaque tour.
if echoTplRetry {
echoTplRetry = false
echoRefuse(reasoningEchoModel(ReadConfig()))
}
if !ep.External {
engineServed() // le slot porte désormais cette requête (llm_slots.go)
// Étape d'un tour de discussion : une compaction en continuation
// pourra la prolonger (chat_compact_cont.go). Sans la clé, rien.
// SLOT_PERSIST s'en sert aussi : seul un slot qui porte un tour de
// discussion est gardé à la bascule de preset.
if ptag.kind == perfMain && (compactContinuationOn(chatCfg) || slotPersistOn(chatCfg)) {
engineMarkMain(reqSeq, ptag.conv)
}
}
toolCalls := map[int]*ToolCall{}
// argBufs : arguments de chaque appel, accumulés sans recopie. `+=` sur la
@@ -1492,6 +1724,10 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
}
// Per-completion reasoning-split state (see reasoningOn comment above).
sawReasoningField := false
// Raisonnement séparé PAR LE SERVEUR, gardé pour être renvoyé
// (REASONING_ECHO). Jamais celui découpé chez nous sur « </think> » : il
// reste dans le contenu, où le gabarit le redécoupe lui-même.
var echoBuf strings.Builder
thinkOpen := reasoningOn
var thinkTail strings.Builder
// Scanner à gros tampon : un chunk peut porter un gros JSON d'arguments
@@ -1719,6 +1955,9 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
if !ep.External {
stats.ReasoningTokens++
}
if pol.on {
echoBuf.WriteString(ch.Delta.ReasoningContent)
}
if !scb(StreamEvent{Reasoning: ch.Delta.ReasoningContent}) {
aborted = true
break
@@ -1824,6 +2063,17 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
scanErr := sc.Err()
resp.Body.Close()
endReq()
// Raisonnement de cette complétion à renvoyer, s'il y a lieu. Il repart
// dans la requête suivante quand elle continue ce tour (appel d'outil),
// ou au tour suivant si le gabarit garde le passé : ctxAfter le compte —
// posé avant toute émission de stats de cette complétion.
echoText := ""
if pol.on && sawReasoningField {
echoText = echoBuf.String()
}
if echoText != "" && (len(toolCalls) > 0 || pol.keepsPast) {
stats.echoed = true
}
// Premier jeton, de quelque nature qu'il soit (raisonnement, texte ou
// appel d'outil). tReq est repris à chaque tentative.
if !tFirst.IsZero() {
@@ -1869,6 +2119,15 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
s.LostAlert = perfAlert(rec, perfLostAlertAt(chatCfg))
}
cb(StreamEvent{Stats: &s})
// Sonde de gabarit (chat_tplprobe.go), en tâche de fond et après coup :
// la complétion est déjà lue, la sonde ne retarde aucun jeton. Fil
// principal seulement, sur une requête de forme normale (pas une
// relance outils coupés ou neutralisés), pour reproduire exactement
// les outils et chat_template_kwargs d'un vrai tour. Rien n'en revient
// dans la requête suivante.
if !ep.External && ptag.kind == perfMain && !disableTools && !toolChoiceNone {
tplProbeKick(tools, reasoningKwargs, reasoningEffort)
}
if !ep.External {
peakGen = max(peakGen, stats.GenTokens)
if lastEst > 0 && stats.PromptTokensTotal > 0 {
@@ -1877,6 +2136,9 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
}
lastEst = 0
}
// KEEP_TURN_IMAGES : coût des images de l'étape précédente, mesuré sur
// cette complétion ; gardées ou rendues éphémères. Rien sans la clé.
extra = kt.observe(complete, stats, messages, extra)
if finishReason == "length" {
logCtx("finish=length prompt=%d généré=%d raisonnement=%d fenêtre=%d", stats.PromptTokensTotal, stats.GenTokens, stats.ReasoningTokens, ctxWindow())
}
@@ -1962,6 +2224,9 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
if s := assistantContent.String(); s != "" {
assistant.Content = s
}
if echoText != "" {
assistant.ReasoningContent, assistant.ReasoningModel = echoText, pol.model
}
result := preflight.msg + " (écriture interrompue avant la fin : rien n'a été modifié)"
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: preflight.name, Label: preflight.file, Done: true, Result: result}})
toolMsg := Message{Role: "tool", ToolCallID: id, Content: result}
@@ -2016,6 +2281,9 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
if s := assistantContent.String(); s != "" {
assistant.Content = s
}
if echoText != "" {
assistant.ReasoningContent, assistant.ReasoningModel = echoText, pol.model
}
messages = append(messages, assistant)
extra = append(extra, assistant)
// Début des résultats de CETTE étape, que le moteur n'a pas encore
@@ -2287,10 +2555,14 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
// contexte (vu en production : requêtes de 55 000 tokens pour une
// fenêtre de 32 768, plus aucun tour ne passait). Le modèle regarde
// l'image MAINTENANT et sa description textuelle, elle, reste.
//
// KEEP_TURN_IMAGES (opt-in, chat_keep_images.go) : gardée dans
// l'historique, par référence, sous un budget mesuré — kt.relay.
// Sans la clé, kt est nil et relay fait exactement l'ajout d'avant.
if tc.Function.Name == "web_screenshot" {
if rel := capturedRelPath(result); rel != "" {
if imgMsg, ok := screenshotImageMessage(rel); ok {
messages = append(messages, imgMsg)
messages, extra = kt.relay(messages, extra, imgMsg, stepStart)
}
}
}
@@ -2301,7 +2573,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
// dépasser la fenêtre pour de bon. Le modèle regarde l'image
// maintenant ; ce qu'il en dit, lui, reste.
if visionImg != nil {
messages = append(messages, seeImageMessage(label, visionImg))
messages, extra = kt.relay(messages, extra, seeImageMessage(label, visionImg), stepStart)
}
}
// Compaction EN COURS DE TOUR. Le seuil n'était testé qu'AU DÉBUT du tour :
@@ -2320,10 +2592,33 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
used = base + estimateTokens(messages[stepStart:])
lastEst = used
}
if compactNeeded(messages, used, peakGen) {
// COMPACT_CONTINUATION (chat_compact_cont.go) : tour de discussion
// seulement, ni tâche ni sous-agent. Sans la clé, rien ne change.
mainConv := ""
if ptag.kind == perfMain {
mainConv = ptag.conv
}
// KEEP_TURN_IMAGES : les images gardées partent AVANT tout résumé ;
// la compaction ne suit que si elle reste nécessaire. Sans la clé,
// aucune image gardée : rien ne change.
if hasKeptImages(messages) && compactNeeded(messages, used, peakGen) {
if s, freed := dropKeptImages(messages); freed > 0 {
logKeptImagesDropped("en-tour", freed)
messages = s
if used > 0 {
used = max(used-freed, 1)
lastEst = used
}
extra = nil
publishHistory()
}
}
if compactNeeded(messages, used, peakGen) && !compactRefusedSkip(mainConv, used, messages) {
opt := compactOptsMid(chatCfg, ep, mainConv, messages, used, tools, reasoningEffort, reasoningKwargs, pol,
disableTools || toolChoiceNone)
yes, no := true, false
cb(StreamEvent{Compacting: &yes})
c, changed := compactMessages(ctx, messages, caps)
c, changed := compactMessagesNoted(ctx, messages, caps, opt)
cb(StreamEvent{Compacting: &no})
logCompact("en-tour", used, messages, c, changed)
if changed {
@@ -2333,7 +2628,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
// repart d'un `extra` vide, sinon l'appelant la ré-empilerait avec
// les messages du tour et dupliquerait tout.
extra = nil
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
newHistory()
}
}
continue
@@ -2346,7 +2641,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
if snippet := textualToolCallSnippet(assistantContent.String()); snippet != "" &&
patternRetries < maxPatternRetries && callsOn {
patternRetries++
bad := Message{Role: "assistant", Content: assistantContent.String()}
bad := withEcho(Message{Role: "assistant", Content: assistantContent.String()}, &ReasoningEcho{Text: echoText, Model: pol.model})
fix := Message{Role: "user", Content: retryCorrective(snippet)}
messages = append(messages, bad, fix)
extra = append(extra, bad, fix)
@@ -2368,6 +2663,18 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
if finishReason == "length" && !ep.External && lengthReplays < 1 &&
compactWouldTrigger(messages, stats.PromptTokensTotal+stats.GenTokens) {
lengthReplays++
// KEEP_TURN_IMAGES : les images gardées d'abord ; le résumé ne suit
// que s'il reste nécessaire. Sans la clé, rien à retirer.
if s, freed := dropKeptImages(messages); freed > 0 {
logKeptImagesDropped("fenêtre-pleine", freed)
messages = s
extra = nil
publishHistory()
if !compactWouldTrigger(messages, max(stats.PromptTokensTotal+stats.GenTokens-freed, 1)) {
cb(StreamEvent{DropReasoning: true})
continue
}
}
yes, no := true, false
cb(StreamEvent{Compacting: &yes})
c, changed := compactMessages(ctx, messages, caps)
@@ -2377,7 +2684,7 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
cb(StreamEvent{DropReasoning: true})
messages = c
extra = nil
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
newHistory()
continue
}
}
@@ -2403,6 +2710,12 @@ func runChatTools(ctx context.Context, messages []Message, tools []Tool, tempera
}
cb(StreamEvent{Content: "_(le modèle n'a pas produit de réponse — finish: " + finishReason + ")_"})
}
// Raisonnement de la réponse finale : l'appelant le range avec elle.
// Jamais sans texte (un message assistant de pur raisonnement serait
// refusé par llama.cpp).
if echoText != "" && strings.TrimSpace(assistantContent.String()) != "" {
cb(StreamEvent{Echo: &ReasoningEcho{Text: echoText, Model: pol.model}})
}
return extra, nil
}
}
+56
View File
@@ -55,6 +55,11 @@ func isLokiInjected(m Message) bool {
if m.Role != "user" {
return false
}
// Relais d'image d'un outil gardé dans l'historique (KEEP_TURN_IMAGES) :
// une image montrée par loki, pas une demande. Jamais posé sans la clé.
if m.ImgRelay {
return true
}
s, ok := m.Content.(string)
if !ok {
return false
@@ -156,3 +161,54 @@ func withTrailingHint(msgs []Message, hint string) []Message {
// Dernier message d'une autre forme (cas théorique) : un message à part.
return append(out, Message{Role: "user", Content: lokiNotePrefix + hint})
}
// nudgeInToolOn : NUDGE_IN_TOOL (opt-in, off par défaut, ZONE GRISE). Sur un
// gabarit dont le rendu d'un tour d'outil change dès qu'un message `user`
// s'ajoute (Qwen3.5 : le nouveau message devient la « dernière question », et
// tous les messages assistant du tour sont rendus autrement), le rappel de
// budget en message à part fait recalculer toute la boucle d'outils du tour.
// Posé au bout du dernier résultat d'outil, il ne coûte que lui-même.
//
// Le prix : une consigne glissée dans une sortie d'outil, que les modèles
// entraînés contre l'injection de prompt peuvent suivre moins bien — d'où
// l'opt-in, à n'activer qu'après avoir comparé l'obéissance aux rappels.
//
// Moteur local seulement, et seulement si la sonde de gabarit
// (chat_tplprobe.go) a conclu « préfixe instable » pour CE modèle : « inconnu »
// ou un autre modèle laissent le message à part.
func nudgeInToolOn(cfg map[string]string, ep chatEndpoint) bool {
switch strings.ToLower(strings.TrimSpace(cfg["NUDGE_IN_TOOL"])) {
case "on", "1", "true", "yes", "oui":
default:
return false
}
if ep.External {
return false
}
r, ok := tplCapsCurrent()
return ok && r.PrefixStable == tplNo && r.Model != "" && r.Model == reasoningEchoModel(cfg)
}
// nudgeIntoTool ajoute le rappel au bout du dernier résultat d'outil, dans la
// vue du modèle ET dans l'historique — le même message, modifié des deux côtés
// à l'identique, pour que le tour suivant rejoue les mêmes octets. Ce résultat
// n'est pas encore parti au moteur : rien de ce qu'il a en cache ne bouge.
// ok=false (dernier message d'une autre forme, historique réécrit entre-temps
// par une compaction) : l'appelant passe par appendNudge. Copies, jamais de
// modification en place.
func nudgeIntoTool(messages, extra []Message, text string) ([]Message, []Message, bool) {
n, e := len(messages), len(extra)
if n == 0 || e == 0 {
return messages, extra, false
}
last, kept := messages[n-1], extra[e-1]
s, ok := last.Content.(string)
ks, kok := kept.Content.(string)
if !ok || !kok || s != ks || last.Role != "tool" || kept.Role != "tool" || last.ToolCallID != kept.ToolCallID {
return messages, extra, false
}
last.Content = s + "\n\n" + text
messages = append(messages[:n-1:n-1], last)
extra = append(extra[:e-1:e-1], last)
return messages, extra, true
}
+16 -1
View File
@@ -112,10 +112,18 @@ func oaiHandler() http.Handler {
return
}
if strings.HasPrefix(p, "/v1") || p == "/health" || p == "/props" || p == "/metrics" || strings.HasPrefix(p, "/slots") {
// SIDE_SLOT : la complétion d'un client passe par le second slot,
// jamais par celui de la discussion (llm_sideslot.go). Sans second
// slot en service, le corps n'est pas lu.
side, status, msg := sideSlotProxyRewrite(r)
if status != 0 {
sendOAIError(w, status, msg, "invalid_request_error", "side_slot")
return
}
// Requête en vol vers le moteur : l'isolation des travaux annexes
// n'efface pas le slot pendant ce temps. Différé : ReverseProxy panique
// (ErrAbortHandler) quand le client coupe en plein flux.
defer engineRequestStart()()
defer engineProxyBegin(r, side)()
// Complétion d'un client externe : elle prend le slot sous le nez de
// la conversation. Notée pour nommer la perte de cache qui suit.
if r.Method == http.MethodPost && strings.HasPrefix(p, "/v1/") {
@@ -127,6 +135,13 @@ func oaiHandler() http.Handler {
"benchmark en cours sur ce moteur — réessaie dans un moment", "server_error", "benchmark_running")
return
}
// Optimisation : le vrai moteur est arrêté, un moteur d'essai tient la carte.
if tuneGuard() != nil {
w.Header().Set("Retry-After", "60")
sendOAIError(w, http.StatusServiceUnavailable,
"optimisation du moteur en cours — réessaie dans quelques minutes", "server_error", "tuning_running")
return
}
perfNoteForeign()
}
lp.ServeHTTP(w, r)
+276
View File
@@ -0,0 +1,276 @@
package loki
// llm_reasoning_echo.go — REASONING_ECHO : renvoyer au moteur LOCAL le
// raisonnement que le modèle a lui-même produit (reasoning_content), au lieu de
// lui montrer ses étapes passées avec des blocs de réflexion vides.
//
// Pourquoi c'est utile. Les gabarits Qwen3.5/3.6 (et d'autres) rendent
// `<think>…</think>` pour chaque message assistant situé après la dernière
// question de l'utilisateur : c'est le format entrelacé sur lequel ils ont été
// entraînés. Loki ne gardait que le texte et les appels d'outils, donc à chaque
// étape d'une boucle d'outils le modèle relisait ses étapes précédentes sans leur
// raisonnement, et le moteur recalculait le dernier message assistant (le rendu
// ne correspondait plus à ce qu'il avait généré). Avec un gabarit qui sait
// conserver la réflexion des tours passés (REASONING_PRESERVE=on, Qwen3.6), le
// préfixe reste en plus stable d'un message utilisateur à l'autre. Qwen3.5 n'a
// pas ce réglage : le gain y reste interne au tour.
//
// Pourquoi c'est OPT-IN (off par défaut). Le raisonnement renvoyé occupe du
// contexte : une longue boucle d'outils atteint la compaction (avec perte) plus
// tôt, même sans REASONING_PRESERVE. Et certains gabarits le refusent (gpt-oss
// sur un moteur ancien lève « Cannot pass both content and thinking »). Sans la
// clé, rien ne change : rien n'est capturé, rien n'est envoyé, à l'octet près.
//
// Les règles, chacune contre un piège précis :
// - moteur local seulement : une API externe (OpenAI, DeepSeek) refuserait ou
// interpréterait le champ à sa façon ;
// - seulement le raisonnement que le SERVEUR a séparé (reasoning_content du
// flux). Le découpage « </think> » fait chez nous reste dans le contenu, où
// le gabarit le redécoupe lui-même : poser aussi ReasoningContent le ferait
// rendre deux fois ;
// - chaque raisonnement porte le nom du modèle qui l'a produit, et ne repart
// que vers ce modèle-là : après un changement de modèle, un gabarit qui rend
// les anciens raisonnements (DeepSeek-V4 avec outils, MiniMax) présenterait
// sinon la réflexion d'une autre famille comme la sienne ;
// - jamais de message assistant qui ne porterait que du raisonnement (llama.cpp
// répond « Expected 'content' or 'tool_calls' ») ;
// - un seul point de sortie (echoMessages, appelé par runChat juste avant le
// marshal) : c'est là que tout est retiré quand la règle ne s'applique pas,
// y compris l'étiquette du modèle, qui ne quitte jamais Loki ;
// - si le gabarit refuse (exception), le tour est rejoué UNE fois sans
// raisonnement, et le refus est retenu pour ce modèle jusqu'au redémarrage,
// avant toute coupure d'outils ;
// - si le prompt déborde, on retente d'abord sans raisonnement — exactement la
// requête d'avant cette clé — avant toute compaction.
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// reasoningEchoEnabled : la clé REASONING_ECHO demande-t-elle l'écho ?
func reasoningEchoEnabled(cfg map[string]string) bool {
switch strings.ToLower(strings.TrimSpace(cfg["REASONING_ECHO"])) {
case "on", "1", "true", "yes", "oui":
return true
}
return false
}
// reasoningEchoModel : l'étiquette du modèle chargé, posée sur chaque
// raisonnement capturé. Le nom du fichier, pas le chemin : le même modèle
// déplacé ou monté ailleurs reste le même. Vide = inconnu, rien ne part.
func reasoningEchoModel(cfg map[string]string) string {
m := strings.TrimSpace(cfg["MODEL"])
if m == "" {
return ""
}
return filepath.Base(m)
}
// echoRefused : modèles dont le gabarit a refusé un raisonnement renvoyé, pour
// toute la vie du processus (même principe que effortFallbacks).
var echoRefused sync.Map
func echoRefuse(model string) {
if model == "" {
return
}
if _, loaded := echoRefused.LoadOrStore(model, true); !loaded {
fmt.Fprintf(os.Stderr, "[reasoning] gabarit de %s : raisonnement renvoyé refusé — REASONING_ECHO suspendu pour ce modèle jusqu'au redémarrage\n", model)
}
}
func echoIsRefused(model string) bool {
_, ok := echoRefused.Load(model)
return ok
}
// echoPolicy : ce qui part au moteur pour cette requête.
// - on : renvoyer le raisonnement (clé posée, moteur local, modèle connu, pas
// refusé par le gabarit, et la sonde ne dit pas qu'il n'est jamais rendu) ;
// - model : seul le raisonnement étiqueté de ce modèle repart ;
// - keepsPast : le gabarit rend aussi le raisonnement des messages d'AVANT la
// dernière question (sonde « oui » ou inconnue). Ne sert qu'au comptage :
// un raisonnement que le gabarit écarte ne coûte rien au prompt.
type echoPolicy struct {
on bool
model string
keepsPast bool
}
// currentEchoPolicy lit la configuration, la mémoire des refus et le dernier
// verdict de la sonde de gabarit (chat_tplprobe.go).
func currentEchoPolicy() echoPolicy {
return echoPolicyFor(ReadConfig())
}
func echoPolicyFor(cfg map[string]string) echoPolicy {
p := echoPolicy{model: reasoningEchoModel(cfg), keepsPast: true}
if !reasoningEchoEnabled(cfg) || isExternalConfig(cfg) || p.model == "" || echoIsRefused(p.model) {
return echoPolicy{}
}
p.on = true
// Verdict de la sonde, s'il concerne bien CE modèle. « jamais rendu » :
// renvoyer ne servirait à rien, on s'abstient. Inconnu : l'utilisateur a
// demandé l'écho, on envoie (le filet d'erreur de gabarit reste là).
if r, ok := tplCapsCurrent(); ok && r.Model == p.model {
if r.RendersReasoning == tplNo {
p.on = false
}
if r.PreservesHistory == tplNo {
p.keepsPast = false
}
}
return p
}
// hasBody : le message assistant porte du texte ou des appels d'outils — ce
// sans quoi llama.cpp refuse un message assistant.
func hasBody(m Message) bool {
if len(m.ToolCalls) > 0 {
return true
}
switch v := m.Content.(type) {
case nil:
return false
case string:
return v != ""
}
return true
}
// echoable : ce raisonnement-là peut-il partir sous cette politique ?
func echoable(m Message, p echoPolicy) bool {
return p.on && m.Role == "assistant" && m.ReasoningContent != "" &&
m.ReasoningModel == p.model && hasBody(m)
}
// echoMessages : LE point de sortie. Rend les messages tels qu'ils partent au
// moteur, et le nombre de raisonnements renvoyés. Aucun message ne porte de
// raisonnement (cas de toujours, clé absente) : la tranche est rendue telle
// quelle, sans copie — la requête est identique à l'octet près. Sinon, copie :
// l'étiquette du modèle est toujours retirée, le raisonnement l'est quand il
// ne doit pas partir.
func echoMessages(msgs []Message, p echoPolicy) ([]Message, int) {
has := false
for i := range msgs {
if msgs[i].ReasoningContent != "" || msgs[i].ReasoningModel != "" {
has = true
break
}
}
if !has {
return msgs, 0
}
out := make([]Message, len(msgs))
n := 0
for i, m := range msgs {
keep := echoable(m, p)
m.ReasoningModel = ""
if keep {
n++
} else {
m.ReasoningContent = ""
}
out[i] = m
}
return out, n
}
// echoTokens : jetons que le raisonnement renvoyé ajoute au prompt, message par
// message (nil = aucun). Compte ce qui part ET que le gabarit rend : après la
// dernière question toujours (rendu au sein du tour par tous les gabarits
// entrelacés), avant elle seulement si le gabarit conserve le passé ou qu'on ne
// le sait pas — dans le doute on compte, le sens sans danger.
func echoTokens(msgs []Message) []int {
found := false
for i := range msgs {
if msgs[i].ReasoningContent != "" {
found = true
break
}
}
if !found {
return nil // cas de toujours : pas même une lecture de configuration
}
return echoTokensFor(msgs, currentEchoPolicy())
}
func echoTokensFor(msgs []Message, p echoPolicy) []int {
if !p.on {
return nil
}
lastUser := -1
for i, m := range msgs {
if m.Role == "user" {
lastUser = i
}
}
var out []int
for i, m := range msgs {
if !echoable(m, p) || (i < lastUser && !p.keepsPast) {
continue
}
if out == nil {
out = make([]int, len(msgs))
}
out[i] = len(m.ReasoningContent) / 4
}
return out
}
// stripReasoning : copie des messages sans aucun raisonnement (étiquette
// comprise) — ce que Loki envoyait avant REASONING_ECHO.
func stripReasoning(msgs []Message) []Message {
out, _ := echoMessages(msgs, echoPolicy{})
return out
}
// echoTemplateError : le moteur a-t-il refusé la requête à cause du gabarit,
// d'une façon que le raisonnement renvoyé peut expliquer ? Exception levée par
// le gabarit (raise_exception, « Cannot pass both content and thinking » de
// gpt-oss), message assistant jugé invalide, messages illisibles. Le refus du
// niveau de raisonnement, lui aussi levé par le gabarit, a son propre filet
// (llm_effort.go) et n'est pas concerné.
func echoTemplateError(status int, body string) bool {
if status != 500 && status != 400 {
return false
}
if _, isEffort := effortRejection(body); isEffort {
return false
}
low := strings.ToLower(body)
// Appel d'outil mal formé (« Failed to parse input at pos N: … ») : le
// texte du modèle y est cité, il peut dire « thinking » sans que le gabarit
// y soit pour rien. Le filet des outils s'en charge, pas celui-ci — sinon
// une relance chanceuse ferait couper la clé pour ce modèle à tort.
if strings.Contains(low, "failed to parse input") {
return false
}
for _, k := range []string{"raise_exception", "expected 'content' or 'tool_calls'", "failed to parse messages"} {
if strings.Contains(low, k) {
return true
}
}
// « thinking » seul ne dit rien : seulement dans une erreur du gabarit.
return strings.Contains(low, "thinking") && (strings.Contains(low, "jinja") || strings.Contains(low, "template"))
}
// ReasoningEcho : raisonnement séparé de la complétion qui porte la réponse
// finale, et le modèle qui l'a produit. L'appelant le range avec cette réponse.
type ReasoningEcho struct {
Text string
Model string
}
// withEcho pose le raisonnement final sur le message de réponse.
func withEcho(m Message, e *ReasoningEcho) Message {
if e != nil && e.Text != "" && hasBody(m) {
m.ReasoningContent, m.ReasoningModel = e.Text, e.Model
}
return m
}
+618
View File
@@ -0,0 +1,618 @@
package loki
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
)
const echoTestModel = "Qwen3.6-27B-Q8_0.gguf"
// echoTestSetup : base de test, modèle connu, sonde vierge, mémoire des refus
// nettoyée. on = REASONING_ECHO posé.
func echoTestSetup(t *testing.T, on bool) {
t.Helper()
withWorkspace(t)
freshTplProbe(t)
if err := SetConfigKey("MODEL", "/models/"+echoTestModel); err != nil {
t.Fatal(err)
}
if on {
if err := SetConfigKey("REASONING_ECHO", "on"); err != nil {
t.Fatal(err)
}
}
echoRefused.Delete(echoTestModel)
t.Cleanup(func() { echoRefused.Delete(echoTestModel) })
}
// moteurEcho : llama-server de comédie. Chaque requête de complétion reçoit
// la réponse de reply (statut, corps SSE ou erreur) ; les corps bruts sont
// gardés pour être comparés. Les requêtes non streamées (résumé de compaction)
// reçoivent un résumé.
type moteurEcho struct {
mu sync.Mutex
bodies []string
summary int
reply func(n int, body string) (int, string)
}
func (m *moteurEcho) 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()
if !strings.Contains(body, `"stream":true`) {
m.mu.Lock()
m.summary++
m.mu.Unlock()
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé des étapes précédentes, avec les faits trouvés"}}}})
return
}
m.mu.Lock()
n := len(m.bodies)
m.bodies = append(m.bodies, body)
m.mu.Unlock()
status, out := m.reply(n, body)
if status != 200 {
w.WriteHeader(status)
_, _ = w.Write([]byte(out))
return
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(out + "data: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
}
func (m *moteurEcho) all() []string {
m.mu.Lock()
defer m.mu.Unlock()
return append([]string(nil), m.bodies...)
}
const sseGlobStep = `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"
// Une boucle d'outils : raisonnement séparé + appel, puis raisonnement + réponse.
func scriptBoucle(n int, _ string) (int, string) {
if n == 0 {
return 200, sseReasoning("je cherche ") + sseReasoning("les fichiers") + sseGlobStep
}
return 200, sseReasoning("j'ai trouvé") + sseChunk("Voilà.") + sseFinal("stop", 120, 5)
}
// requestMessages décode les messages d'un corps de requête.
func requestMessages(t *testing.T, body string) []map[string]any {
t.Helper()
var p struct {
Messages []map[string]any `json:"messages"`
}
if err := json.Unmarshal([]byte(body), &p); err != nil {
t.Fatal(err)
}
return p.Messages
}
// Clé absente : rien n'est capturé ni renvoyé, aucun événement nouveau. La
// requête est celle d'avant la clé.
func TestEchoDefautRienNeChange(t *testing.T) {
echoTestSetup(t, false)
m := &moteurEcho{reply: scriptBoucle}
m.start(t)
var echoes int
extra, err := runChat(context.Background(), []Message{um("liste les fichiers")}, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
if ev.Echo != nil {
echoes++
}
return true
})
if err != nil {
t.Fatal(err)
}
bodies := m.all()
if len(bodies) != 2 {
t.Fatalf("%d requêtes, attendu 2", len(bodies))
}
for i, b := range bodies {
if strings.Contains(b, "reasoning_content") || strings.Contains(b, "reasoning_model") {
t.Fatalf("requête %d : raisonnement envoyé sans la clé : %s", i, b)
}
}
for _, e := range extra {
if e.ReasoningContent != "" || e.ReasoningModel != "" {
t.Fatalf("raisonnement capturé sans la clé : %+v", e)
}
}
if echoes != 0 {
t.Fatalf("%d événements Echo sans la clé", echoes)
}
}
// Point de sortie : sans raisonnement nulle part, la tranche ressort telle
// quelle ; avec du raisonnement stocké mais une politique éteinte, le JSON est
// octet pour octet celui d'avant le champ.
func TestEchoMessagesOctetPourOctet(t *testing.T) {
plain := []Message{
{Role: "system", Content: "sys"},
um("question"),
{Role: "assistant", Content: "je regarde", ToolCalls: []ToolCall{{ID: "c1", Type: "function", Function: ToolCallFunc{Name: "bash", Arguments: "{}"}}}},
tm("sortie"),
am("réponse"),
}
out, n := echoMessages(plain, echoPolicy{on: true, model: echoTestModel, keepsPast: true})
if n != 0 || &out[0] != &plain[0] {
t.Fatal("aucun raisonnement : la tranche doit ressortir sans copie")
}
want, _ := json.Marshal(plain)
stored := append([]Message(nil), plain...)
stored[2].ReasoningContent, stored[2].ReasoningModel = "raisonnement", echoTestModel
stored[4].ReasoningContent, stored[4].ReasoningModel = "autre", echoTestModel
for _, p := range []echoPolicy{{}, {on: true, model: "autre-modele.gguf", keepsPast: true}} {
got, n := echoMessages(stored, p)
b, _ := json.Marshal(got)
if n != 0 || !bytes.Equal(b, want) {
t.Fatalf("politique %+v : %s\nattendu %s", p, b, want)
}
}
if stored[2].ReasoningContent == "" {
t.Fatal("echoMessages a modifié l'historique d'origine")
}
}
// Ce qui part : seulement le raisonnement du même modèle, jamais l'étiquette,
// jamais sur un message assistant sans texte ni appel.
func TestEchoMessagesPolitique(t *testing.T) {
p := echoPolicy{on: true, model: echoTestModel, keepsPast: true}
msgs := []Message{
um("q"),
{Role: "assistant", Content: "a", ReasoningContent: "r1", ReasoningModel: echoTestModel},
{Role: "assistant", Content: "b", ReasoningContent: "r2", ReasoningModel: "Llama-3.gguf"},
{Role: "assistant", ReasoningContent: "r3", ReasoningModel: echoTestModel}, // ni texte ni appel
{Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Type: "function", Function: ToolCallFunc{Name: "bash", Arguments: "{}"}}}, ReasoningContent: "r4", ReasoningModel: echoTestModel},
{Role: "user", Content: "x", ReasoningContent: "r5", ReasoningModel: echoTestModel},
}
out, n := echoMessages(msgs, p)
if n != 2 || out[1].ReasoningContent != "r1" || out[4].ReasoningContent != "r4" {
t.Fatalf("renvoyés : %d, %+v", n, out)
}
for i, m := range out {
if m.ReasoningModel != "" {
t.Fatalf("étiquette du modèle envoyée (message %d)", i)
}
if m.Role == "assistant" && m.ReasoningContent != "" && !hasBody(m) {
t.Fatalf("message assistant de pur raisonnement envoyé (message %d)", i)
}
}
if out[2].ReasoningContent != "" || out[3].ReasoningContent != "" || out[5].ReasoningContent != "" {
t.Fatalf("raisonnement envoyé à tort : %+v", out)
}
}
func TestEchoPolicyFor(t *testing.T) {
echoTestSetup(t, false)
base := map[string]string{"REASONING_ECHO": "on", "MODEL": "/models/" + echoTestModel}
with := func(k, v string) map[string]string {
c := map[string]string{}
for a, b := range base {
c[a] = b
}
c[k] = v
return c
}
if p := echoPolicyFor(base); !p.on || p.model != echoTestModel || !p.keepsPast {
t.Fatalf("clé posée : %+v", p)
}
if p := echoPolicyFor(with("REASONING_ECHO", "")); p.on {
t.Fatal("clé absente : écho actif")
}
if p := echoPolicyFor(with(extKeyFlag, "1")); p.on {
t.Fatal("preset externe : écho actif")
}
if p := echoPolicyFor(with("MODEL", "")); p.on {
t.Fatal("modèle inconnu : écho actif")
}
echoRefuse(echoTestModel)
if p := echoPolicyFor(base); p.on {
t.Fatal("gabarit qui a refusé : écho actif")
}
echoRefused.Delete(echoTestModel)
// Verdicts de la sonde, seulement pour CE modèle.
set := func(r tplProbeResult) {
tplProbeMu.Lock()
tplProbeLast = &r
tplProbeMu.Unlock()
}
set(tplProbeResult{Model: echoTestModel, RendersReasoning: tplNo, PreservesHistory: tplNo})
if p := echoPolicyFor(base); p.on {
t.Fatal("gabarit qui ne rend jamais le raisonnement : écho actif")
}
set(tplProbeResult{Model: echoTestModel, RendersReasoning: tplYes, PreservesHistory: tplNo})
if p := echoPolicyFor(base); !p.on || p.keepsPast {
t.Fatalf("gabarit façon Qwen3.5 : %+v", p)
}
set(tplProbeResult{Model: "autre.gguf", RendersReasoning: tplNo, PreservesHistory: tplNo})
if p := echoPolicyFor(base); !p.on || !p.keepsPast {
t.Fatalf("verdict d'un autre modèle appliqué : %+v", p)
}
}
// Comptage : le raisonnement d'après la dernière question compte toujours,
// celui d'avant seulement si le gabarit garde le passé (ou qu'on l'ignore).
func TestEchoTokensSuitLeRendu(t *testing.T) {
r := strings.Repeat("r", 400) // 100 jetons
msgs := []Message{
um("q1"),
{Role: "assistant", Content: "a", ReasoningContent: r, ReasoningModel: echoTestModel},
um("q2"),
{Role: "assistant", Content: "b", ReasoningContent: r, ReasoningModel: echoTestModel},
}
sum := func(v []int) int {
s := 0
for _, n := range v {
s += n
}
return s
}
if got := sum(echoTokensFor(msgs, echoPolicy{on: true, model: echoTestModel, keepsPast: true})); got != 200 {
t.Fatalf("gabarit qui garde le passé : %d, attendu 200", got)
}
if got := sum(echoTokensFor(msgs, echoPolicy{on: true, model: echoTestModel})); got != 100 {
t.Fatalf("gabarit qui retire le passé : %d, attendu 100", got)
}
if got := echoTokensFor(msgs, echoPolicy{}); got != nil {
t.Fatalf("écho coupé : %v", got)
}
// estimateTokens suit la configuration réelle.
echoTestSetup(t, false)
without := estimateTokens(msgs)
if err := SetConfigKey("REASONING_ECHO", "on"); err != nil {
t.Fatal(err)
}
if got := estimateTokens(msgs); got != without+200 {
t.Fatalf("estimateTokens avec l'écho = %d, attendu %d", got, without+200)
}
}
// shrinkToFit retire d'abord le raisonnement, du plus ancien au plus récent,
// même dans un seul long tour sans frontière utilisateur.
func TestShrinkRetireDAbordLeRaisonnement(t *testing.T) {
echoTestSetup(t, true)
if err := SetConfigKey("CTX", "8192"); err != nil {
t.Fatal(err)
}
r := strings.Repeat("r", 8000) // 2000 jetons chacun
msgs := []Message{um("corrige le build")}
for i := 0; i < 4; i++ {
msgs = append(msgs,
Message{Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Type: "function", Function: ToolCallFunc{Name: "bash", Arguments: "{}"}}}, ReasoningContent: r, ReasoningModel: echoTestModel},
tm("sortie courte"))
}
out, changed := shrinkToFit(msgs, 0)
if !changed {
t.Fatal("rien retiré")
}
if len(out) != len(msgs) {
t.Fatalf("des messages ont été retirés (%d → %d) alors que le raisonnement suffisait", len(msgs), len(out))
}
if out[1].ReasoningContent != "" || out[len(out)-2].ReasoningContent == "" {
t.Fatal("ordre : le plus ancien raisonnement doit partir en premier, le plus récent rester")
}
for i, m := range out {
if m.Role == "tool" && msgText(m) != "sortie courte" {
t.Fatalf("résultat d'outil %d tronqué avant le raisonnement", i)
}
}
if msgs[1].ReasoningContent == "" {
t.Fatal("shrinkToFit a modifié l'historique d'origine")
}
}
// Clé posée : le raisonnement séparé de l'étape d'outil repart avec elle, sans
// étiquette ; celui de la réponse finale est rendu à l'appelant.
func TestEchoRenvoieLeRaisonnementSepare(t *testing.T) {
echoTestSetup(t, true)
m := &moteurEcho{reply: scriptBoucle}
m.start(t)
var echo *ReasoningEcho
extra, err := runChat(context.Background(), []Message{um("liste les fichiers")}, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
if ev.Echo != nil {
echo = ev.Echo
}
return true
})
if err != nil {
t.Fatal(err)
}
if len(extra) < 1 || extra[0].ReasoningContent != "je cherche les fichiers" || extra[0].ReasoningModel != echoTestModel {
t.Fatalf("message tool_calls : %+v", extra)
}
bodies := m.all()
if len(bodies) != 2 {
t.Fatalf("%d requêtes", len(bodies))
}
if strings.Contains(bodies[1], "reasoning_model") {
t.Fatal("étiquette du modèle envoyée au moteur")
}
msgs := requestMessages(t, bodies[1])
var found bool
for _, mm := range msgs {
if mm["role"] == "assistant" && mm["reasoning_content"] == "je cherche les fichiers" {
found = true
}
}
if !found {
t.Fatalf("raisonnement de l'étape non renvoyé : %s", bodies[1])
}
if echo == nil || echo.Text != "j'ai trouvé" || echo.Model != echoTestModel {
t.Fatalf("raisonnement final : %+v", echo)
}
}
// Découpage « </think> » fait chez nous : jamais copié dans ReasoningContent
// (le gabarit le redécouperait, rendu en double).
func TestEchoJamaisDepuisLeDecoupageClient(t *testing.T) {
echoTestSetup(t, true)
if err := SetConfigKey("REASONING", "on"); err != nil {
t.Fatal(err)
}
m := &moteurEcho{reply: func(n int, _ string) (int, string) {
if n == 0 {
return 200, sseChunk("je pense") + sseChunk("</think>") + sseChunk("je regarde") + sseGlobStep
}
return 200, sseChunk("pensée</think>") + sseChunk("Voilà.") + sseFinal("stop", 100, 4)
}}
m.start(t)
var echo *ReasoningEcho
extra, err := runChat(context.Background(), []Message{um("liste")}, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
if ev.Echo != nil {
echo = ev.Echo
}
return true
})
if err != nil {
t.Fatal(err)
}
for _, b := range m.all() {
if strings.Contains(b, "reasoning_content") {
t.Fatalf("raisonnement découpé chez nous renvoyé à part : %s", b)
}
}
if len(extra) < 1 || extra[0].ReasoningContent != "" || msgText(extra[0]) != "je pense</think>je regarde" {
t.Fatalf("message tool_calls : %+v", extra)
}
if echo != nil {
t.Fatalf("raisonnement final tiré du découpage client : %+v", echo)
}
}
// Une relance « pensé sans agir » (DropReasoning) : seul le raisonnement de la
// complétion retenue accompagne la réponse.
func TestEchoApresDropReasoning(t *testing.T) {
echoTestSetup(t, true)
m := &moteurEcho{reply: func(n int, _ string) (int, string) {
if n == 0 {
return 200, sseReasoning("mort-né") + sseFinal("stop", 100, 3)
}
return 200, sseReasoning("retenu") + sseChunk("Fait.") + sseFinal("stop", 110, 3)
}}
m.start(t)
var echo *ReasoningEcho
var dropped bool
if _, err := runChat(context.Background(), []Message{um("fais-le")}, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
if ev.Echo != nil {
echo = ev.Echo
}
if ev.DropReasoning {
dropped = true
}
return true
}); err != nil {
t.Fatal(err)
}
if !dropped || echo == nil || echo.Text != "retenu" {
t.Fatalf("après relance : dropped=%v echo=%+v", dropped, echo)
}
}
// generate range le raisonnement final avec la réponse, et rien sans la clé.
func TestGenerateRangeLeRaisonnement(t *testing.T) {
for _, on := range []bool{false, true} {
echoTestSetup(t, on)
m := &moteurEcho{reply: func(int, string) (int, string) {
return 200, sseReasoning("réflexion") + sseChunk("Bonjour.") + sseFinal("stop", 50, 3)
}}
m.start(t)
c := newTestConv()
c.Messages = []Message{um("salut")}
c.generate(context.Background(), Caps{}, 0.7, c.epoch)
last := c.Messages[len(c.Messages)-1]
if msgText(last) != "Bonjour." {
t.Fatalf("on=%v : dernier message %+v", on, last)
}
if on && (last.ReasoningContent != "réflexion" || last.ReasoningModel != echoTestModel) {
t.Fatalf("clé posée : raisonnement non rangé : %+v", last)
}
if !on && (last.ReasoningContent != "" || last.ReasoningModel != "") {
t.Fatalf("clé absente : raisonnement rangé : %+v", last)
}
}
}
// Historique qui porte un raisonnement de ce modèle.
func echoHistory() []Message {
return []Message{
um("question"),
{Role: "assistant", Content: "préambule", ToolCalls: []ToolCall{{ID: "c1", Type: "function", Function: ToolCallFunc{Name: "glob", Arguments: `{"pattern":"*"}`}}},
ReasoningContent: "réflexion passée", ReasoningModel: echoTestModel},
tm("a.go"),
}
}
// Gabarit qui refuse (gpt-oss sur un moteur ancien) : rejoué une fois sans
// raisonnement, outils et prompt intacts, et le refus est retenu.
func TestEchoErreurDeGabaritRelanceSansRaisonnement(t *testing.T) {
echoTestSetup(t, true)
m := &moteurEcho{reply: func(_ int, body string) (int, string) {
if strings.Contains(body, "reasoning_content") {
return 500, `{"error":{"code":500,"message":"Jinja Exception: raise_exception('Cannot pass both content and thinking in an assistant message with tool calls')"}}`
}
return 200, sseChunk("Réponse.") + sseFinal("stop", 80, 2)
}}
m.start(t)
var content strings.Builder
if _, err := runChat(context.Background(), echoHistory(), 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
content.WriteString(ev.Content)
return true
}); err != nil {
t.Fatal(err)
}
bodies := m.all()
if len(bodies) != 2 || content.String() != "Réponse." {
t.Fatalf("%d requêtes, réponse %q", len(bodies), content.String())
}
if strings.Contains(bodies[1], "reasoning_content") || strings.Contains(bodies[1], `"tool_choice"`) ||
!strings.Contains(bodies[1], `"tools"`) {
t.Fatalf("relance : raisonnement retiré, outils intacts attendus : %s", bodies[1])
}
if !echoIsRefused(echoTestModel) {
t.Fatal("refus du gabarit non retenu")
}
if p := currentEchoPolicy(); p.on {
t.Fatal("écho encore actif après le refus")
}
}
// Erreur de gabarit qui n'a rien à voir avec le raisonnement : la relance
// échoue aussi, et rien n'est retenu contre l'écho.
func TestEchoErreurDeGabaritSansRapport(t *testing.T) {
echoTestSetup(t, true)
m := &moteurEcho{reply: func(int, string) (int, string) {
return 500, `{"error":{"code":500,"message":"Jinja Exception: raise_exception('Conversation roles must alternate')"}}`
}}
m.start(t)
if _, err := runChat(context.Background(), echoHistory(), 0.7, Caps{}, func(StreamEvent) bool { return true }); err == nil {
t.Fatal("erreur attendue")
}
bodies := m.all()
if len(bodies) < 2 || !strings.Contains(bodies[0], "reasoning_content") {
t.Fatalf("%d requêtes ; la première doit porter le raisonnement", len(bodies))
}
// Ensuite, les filets habituels (tool_choice none, outils coupés) partent
// sans raisonnement : il n'est plus envoyé de ce tour.
for i, b := range bodies[1:] {
if strings.Contains(b, "reasoning_content") {
t.Fatalf("requête %d : raisonnement renvoyé après le repli", i+1)
}
}
if echoIsRefused(echoTestModel) {
t.Fatal("refus retenu alors que le raisonnement n'y était pour rien")
}
}
// Prompt trop long avec du raisonnement : relance sans lui AVANT toute
// compaction (aucune perte d'information).
func TestEchoDebordementRelanceSansRaisonnementAvantCompaction(t *testing.T) {
echoTestSetup(t, true)
m := &moteurEcho{reply: func(_ int, body string) (int, string) {
if strings.Contains(body, "reasoning_content") {
return 400, `{"error":{"code":400,"message":"the request exceeds the available context size, try increasing it","type":"exceed_context_size_error"}}`
}
return 200, sseChunk("Réponse.") + sseFinal("stop", 80, 2)
}}
m.start(t)
var compacted bool
if _, err := runChat(context.Background(), echoHistory(), 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
if ev.NewHistory != nil {
compacted = true
}
return true
}); err != nil {
t.Fatal(err)
}
bodies := m.all()
if len(bodies) != 2 || strings.Contains(bodies[1], "reasoning_content") {
t.Fatalf("%d requêtes ; relance sans raisonnement attendue", len(bodies))
}
m.mu.Lock()
summaries := m.summary
m.mu.Unlock()
if compacted || summaries != 0 {
t.Fatalf("compaction avant la relance sans raisonnement (résumés=%d)", summaries)
}
if echoIsRefused(echoTestModel) {
t.Fatal("un débordement n'est pas un refus du gabarit")
}
}
// Passe de correction du mode Code : une compaction survenue pendant la passe
// REMPLACE l'historique, comme dans generate, au lieu d'être perdue.
func TestBuilderAppliqueNewHistory(t *testing.T) {
echoTestSetup(t, false)
convEnsureActive()
m := &moteurEcho{reply: func(n int, _ string) (int, string) {
if n == 0 {
return 400, `{"error":{"code":400,"message":"request (99999 tokens) exceeds the available context size"}}`
}
return 200, sseChunk("Corrigé.") + sseFinal("stop", 80, 2)
}}
m.start(t)
c := newTestConv()
c.Messages = []Message{um("fais le build")}
for i := 0; i < 12; i++ {
c.Messages = append(c.Messages, am("étape "+strings.Repeat("détail ", 300)), um("continue "+strings.Repeat("x", 600)))
}
before := len(c.Messages)
c.runBuilderTurn(context.Background(), Caps{Agent: true, Code: true}, 0.2, c.epoch, "Fix the failing test.")
if len(c.Messages) >= before {
t.Fatalf("compaction perdue : %d messages (avant %d)", len(c.Messages), before)
}
if !strings.HasPrefix(msgText(c.Messages[0]), compactSummaryPrefix) {
t.Fatalf("premier message : %q", msgText(c.Messages[0]))
}
if c.Messages[0].Role == "system" {
t.Fatal("préfixe système injecté persisté")
}
fix := 0
for _, mm := range c.Messages {
if msgText(mm) == "Fix the failing test." {
fix++
}
}
if fix != 1 || msgText(c.Messages[len(c.Messages)-1]) != "Corrigé." {
t.Fatalf("consigne présente %d fois, dernier message %q", fix, msgText(c.Messages[len(c.Messages)-1]))
}
}
// Corrections de relecture du lot 2 : un refus de gabarit est reconnu, une
// erreur d'analyse d'appel d'outil qui cite le mot « thinking » ne l'est pas.
func TestEchoTemplateErrorEtroit(t *testing.T) {
cases := []struct {
status int
body string
want bool
}{
{500, `{"error":{"message":"Jinja Exception: raise_exception('Cannot pass both content and thinking')"}}`, true},
{500, `{"error":{"message":"Error in template: thinking blocks not allowed here"}}`, true},
{400, `{"error":{"message":"Expected 'content' or 'tool_calls'"}}`, true},
{500, `{"error":{"message":"Failed to parse input at pos 12: <tool_call>{\"thinking\": 1}"}}`, false},
{500, `{"error":{"message":"the model kept thinking forever"}}`, false},
{502, `{"error":{"message":"raise_exception"}}`, false},
}
for _, c := range cases {
if got := echoTemplateError(c.status, c.body); got != c.want {
t.Errorf("%d %s : %v", c.status, c.body, got)
}
}
}
+175
View File
@@ -0,0 +1,175 @@
package loki
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// Routage des requêtes entre les deux slots de SIDE_SLOT (backend_serve_side.go).
//
// Sans id_slot, llama-server choisit le slot par similarité de prompt ou, à
// défaut, le moins récemment servi : un vérificateur peut alors tomber sur le
// slot 0 et écraser la conversation — exactement ce que la clé doit empêcher.
// Tant que le second slot est en service, CHAQUE requête de Loki porte donc son
// id_slot : 0 pour le fil de la discussion (tour, étapes d'outils,
// préchauffage, résumé en continuation, qui prolonge le prompt du slot 0), 1
// pour tout le reste (vérification, sous-agent, tâche, bench, résumé sur
// transcription, clients /v1).
//
// « En service » se lit sur le moteur, pas dans la configuration : /props doit
// annoncer 2 slots d'au moins CTX jetons chacun, ce que seul un lancement
// SIDE_SLOT accepté produit. Un moteur relancé sans la clé, refusé ou pas encore
// prêt : aucun id_slot, la requête est celle d'avant. Et sur un seul slot, un
// id_slot explicite ferait même sauter le rechargement depuis le cache RAM
// (f_keep indéfini sur un slot vide) : il ne doit jamais y partir.
// sideSlotProbeTTL : durée pendant laquelle la lecture de /props vaut. Un
// moteur relancé entre-temps sur un seul slot recevrait au pire un id_slot 1,
// que llama.cpp ramène au slot 0 : le comportement d'avant la clé.
const sideSlotProbeTTL = 10 * time.Second
var sideSlotProbe struct {
mu sync.Mutex
key string
at time.Time
live bool
}
// sideSlotProps lit /props du moteur local : nombre de slots et fenêtre d'un
// slot. Remplaçable par les tests.
var sideSlotProps = func(ep chatEndpoint) (slots, nCtx int, ok bool) {
var props struct {
TotalSlots int `json:"total_slots"`
Default struct {
NCtx int `json:"n_ctx"`
} `json:"default_generation_settings"`
}
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, 0, false
}
return props.TotalSlots, props.Default.NCtx, true
}
// sideSlotLive : le second slot est-il en service sur le moteur local ? Sans la
// clé, ni lecture ni requête : faux tout de suite.
func sideSlotLive(ep chatEndpoint) bool {
if ep.External {
return false
}
cfg := ReadConfig()
if !sideSlotOn(cfg) || isExternalConfig(cfg) {
return false
}
win := ctxWindow()
key := fmt.Sprintf("%d|%d", LLMPort(), win)
sideSlotProbe.mu.Lock()
defer sideSlotProbe.mu.Unlock()
if sideSlotProbe.key == key && time.Since(sideSlotProbe.at) < sideSlotProbeTTL {
return sideSlotProbe.live
}
slots, nCtx, ok := sideSlotProps(ep)
if !ok {
// Moteur injoignable (relance en cours) : rien de retenu, la prochaine
// requête redemande.
return false
}
// llama.cpp arrondit la fenêtre d'un slot au multiple de 256 supérieur.
live := slots == 2 && nCtx >= win
sideSlotProbe.key, sideSlotProbe.at, sideSlotProbe.live = key, time.Now(), live
return live
}
// engineSlotFor : l'id_slot d'une requête vers ep, -1 = aucun (la requête part
// telle qu'avant la clé). main : fil de la discussion.
func engineSlotFor(ep chatEndpoint, main bool) int {
if !sideSlotLive(ep) {
return -1
}
if main {
return 0
}
return 1
}
// slotIsMain : les complétions de cette nature restent sur le slot de la
// discussion. Toutes les autres (vérification, sous-agent, tâche, bench,
// résumé) passent par le second.
func slotIsMain(kind string) bool {
return kind == perfMain || kind == perfPrewarm
}
// setEngineSlot pose id_slot dans payload ; slot < 0 : rien.
func setEngineSlot(payload map[string]any, slot int) {
if slot >= 0 {
payload["id_slot"] = slot
}
}
// payloadOnSideSlot : la requête part-elle sur le second slot ? Elle ne touche
// alors pas au slot de la discussion (engineRequestBeginSide).
func payloadOnSideSlot(payload map[string]any) bool {
s, ok := payload["id_slot"].(int)
return ok && s == 1
}
// sideSlotProxyMaxBody : au-delà, un corps de client /v1 n'est pas réécrit mais
// refusé — le laisser passer sans id_slot, c'est risquer le slot 0.
const sideSlotProxyMaxBody = 128 << 20
// sideSlotProxyRewrite pose id_slot 1 dans le corps JSON d'une requête POST
// /v1/* d'un client externe, quel que soit l'id_slot qu'il demandait : seul
// Loki parle au slot de la discussion. Sans second slot en service, rien n'est
// lu ni touché (side=false). Un corps qui n'est pas un objet JSON passe tel
// quel : le moteur le refusera de lui-même. status != 0 : refuser la requête
// avec ce code et ce message.
func sideSlotProxyRewrite(r *http.Request) (side bool, status int, msg string) {
if r.Method != http.MethodPost || !strings.HasPrefix(r.URL.Path, "/v1/") || r.Body == nil {
return false, 0, ""
}
if !sideSlotLive(resolveChatEndpoint()) {
return false, 0, ""
}
raw, err := io.ReadAll(io.LimitReader(r.Body, sideSlotProxyMaxBody+1))
r.Body.Close()
if err != nil {
return false, http.StatusBadRequest, "corps illisible : " + err.Error()
}
if len(raw) > sideSlotProxyMaxBody {
return false, http.StatusRequestEntityTooLarge, "corps trop gros pour être routé vers le slot des clients"
}
var obj map[string]json.RawMessage
if json.Unmarshal(raw, &obj) == nil && obj != nil {
obj["id_slot"] = json.RawMessage("1")
if b, err := json.Marshal(obj); err == nil {
raw, side = b, true
}
}
r.Body = io.NopCloser(bytes.NewReader(raw))
r.ContentLength = int64(len(raw))
r.Header.Set("Content-Length", strconv.Itoa(len(raw)))
return side, 0, ""
}
// reqExceedsCtx : le libellé de llama.cpp pour un prompt plus long que la
// fenêtre d'un slot.
var reqExceedsCtx = regexp.MustCompile(`(?i)exceeds the available context size|request \(\d+ tokens\)`)
// sideSlotPoolError : « Context size has been exceeded. » sans le nombre de
// jetons de la requête n'est pas un prompt trop long, c'est le cache KV qui a
// manqué de place pendant le calcul. Avec deux slots, ce serait la faute du
// voisin, pas de la conversation : la compacter ou la tronquer perdrait de
// l'information pour rien. Avec des flux séparés (--no-kv-unified) ça ne doit
// pas arriver ; le filet ne joue que si le second slot est en service.
func sideSlotPoolError(msg string) bool {
return strings.Contains(strings.ToLower(msg), "context size has been exceeded") && !reqExceedsCtx.MatchString(msg)
}
+230
View File
@@ -0,0 +1,230 @@
package loki
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// stubSideProps remplace la lecture de /props : slots, fenêtre d'un slot.
func stubSideProps(t *testing.T, slots, nCtx int) {
t.Helper()
prev := sideSlotProps
sideSlotProps = func(chatEndpoint) (int, int, bool) { return slots, nCtx, true }
reset := func() {
sideSlotProbe.mu.Lock()
sideSlotProbe.key, sideSlotProbe.live = "", false
sideSlotProbe.mu.Unlock()
}
reset()
t.Cleanup(func() { sideSlotProps = prev; reset() })
}
// slotOf : l'id_slot d'un corps de requête ; -1 = absent.
func slotOf(t *testing.T, body string) int {
t.Helper()
var p map[string]any
if err := json.Unmarshal([]byte(body), &p); err != nil {
t.Fatalf("corps illisible : %v", err)
}
v, ok := p["id_slot"]
if !ok {
return -1
}
return int(v.(float64))
}
// Le tour, une vérification puis un résumé sur transcription : un corps par
// requête, dans l'ordre.
func sideSlotScenario(t *testing.T, m *moteurCont) []string {
t.Helper()
ctx := withPerf(context.Background(), perfMain, convActiveID())
if _, err := runChat(ctx, []Message{um("bonjour")}, 0.2, Caps{}, func(StreamEvent) bool { return true }); err != nil {
t.Fatal(err)
}
if _, err := runChat(withPerfKind(ctx, perfVerify), []Message{um("vérifie")}, 0.2, Caps{}, func(StreamEvent) bool { return true }); err != nil {
t.Fatal(err)
}
if _, err := summarizeTranscriptFor(ctx, "user: a\nassistant: b", false); err != nil {
t.Fatal(err)
}
b := m.all()
if len(b) != 3 {
t.Fatalf("%d requêtes, attendu 3", len(b))
}
return b
}
// Sans la clé : aucun id_slot, et /props n'est même pas lu.
func TestSideSlotRoutageDefaut(t *testing.T) {
m := contSetup(t, nil)
prev := sideSlotProps
sideSlotProps = func(chatEndpoint) (int, int, bool) { t.Fatal("/props lu sans la clé"); return 0, 0, false }
t.Cleanup(func() { sideSlotProps = prev })
for _, b := range sideSlotScenario(t, m) {
if strings.Contains(b, "id_slot") {
t.Fatalf("id_slot sans la clé : %s", b)
}
}
}
// Avec deux slots en service : le tour sur le 0, la vérification et la
// transcription sur le 1 — et le slot 0 porte toujours le tour après elles.
func TestSideSlotRoutage(t *testing.T) {
m := contSetup(t, map[string]string{"SIDE_SLOT": "on", "COMPACT_CONTINUATION": "on"})
stubSideProps(t, 2, 8192)
b := sideSlotScenario(t, m)
for i, want := range []int{0, 1, 1} {
if got := slotOf(t, b[i]); got != want {
t.Errorf("requête %d : id_slot %d, attendu %d", i, got, want)
}
}
if !engineSlotHolds(convActiveID()) {
t.Fatal("le slot 0 n'a pas bougé : le tampon du tour doit tenir après le second slot")
}
}
// Un seul slot, une fenêtre trop petite (lancement refusé, PARALLEL=2 seul) ou
// un preset externe : aucun id_slot.
func TestSideSlotPasEnService(t *testing.T) {
testHome(t)
if err := SetConfigKey("SIDE_SLOT", "on"); err != nil {
t.Fatal(err)
}
if err := SetConfigKey("CTX", "8192"); err != nil {
t.Fatal(err)
}
for _, c := range []struct{ slots, nCtx int }{{1, 8192}, {2, 4096}, {4, 8192}} {
stubSideProps(t, c.slots, c.nCtx)
if s := engineSlotFor(chatEndpoint{}, false); s != -1 {
t.Errorf("%d slots de %d : id_slot %d", c.slots, c.nCtx, s)
}
}
stubSideProps(t, 2, 8448) // arrondi de llama.cpp au multiple de 256
if engineSlotFor(chatEndpoint{}, true) != 0 || engineSlotFor(chatEndpoint{}, false) != 1 {
t.Fatal("deux slots de CTX jetons : routage attendu")
}
if s := engineSlotFor(chatEndpoint{External: true}, false); s != -1 {
t.Fatalf("preset externe : id_slot %d", s)
}
}
func TestSideSlotPoolError(t *testing.T) {
for msg, want := range map[string]bool{
`{"error":{"code":500,"message":"Context size has been exceeded.","type":"server_error"}}`: true,
`{"error":{"message":"request (40000 tokens) exceeds the available context size (32768 tokens), try increasing it","type":"exceed_context_size_error"}}`: false,
`the request exceeds the available context size`: false,
`failed to parse tool call`: false,
} {
if got := sideSlotPoolError(msg); got != want {
t.Errorf("%q : %v", msg, got)
}
}
}
// Cache KV plein en plein calcul, deux slots en service : la requête est
// rejouée telle quelle, sans compaction ni réduction.
func TestSideSlotPoolErrorRejoue(t *testing.T) {
testHome(t)
var mu sync.Mutex
var bodies []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
mu.Lock()
bodies = append(bodies, string(b))
n := len(bodies)
mu.Unlock()
if n == 1 {
sendJSON(w, 500, map[string]any{"error": map[string]any{"message": "Context size has been exceeded."}})
return
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(sseChunk("ok") + sseFinal("stop", 50, 1) + "data: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
_, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
for k, v := range map[string]string{"PORT": port, "SIDE_SLOT": "on", "CTX": "8192"} {
if err := SetConfigKey(k, v); err != nil {
t.Fatal(err)
}
}
stubSideProps(t, 2, 8192)
msgs := []Message{um(strings.Repeat("x", 30000))} // près de la fenêtre : l'estimation crierait au débordement
if _, err := runChat(withPerf(context.Background(), perfMain, "c"), msgs, 0.2, Caps{}, func(StreamEvent) bool { return true }); err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if len(bodies) != 2 || bodies[0] != bodies[1] {
t.Fatalf("%d requêtes ; rejeu identique attendu", len(bodies))
}
}
// Client /v1 : son corps part avec id_slot 1, quoi qu'il demande. Sans la clé,
// il passe octet pour octet.
func TestSideSlotProxyForceLeSecondSlot(t *testing.T) {
testHome(t)
var got []byte
fauxMoteur(t, func(w http.ResponseWriter, r *http.Request) {
got, _ = io.ReadAll(r.Body)
if r.ContentLength != int64(len(got)) {
t.Errorf("Content-Length %d pour %d octets", r.ContentLength, len(got))
}
sendJSON(w, 200, map[string]any{"ok": true})
})
in := `{"model":"x","id_slot":0,"seed":12345678901234567890,"messages":[{"role":"user","content":"a"}]}`
call := func() {
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader([]byte(in)))
rec := httptest.NewRecorder()
oaiHandler().ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("statut %d : %s", rec.Code, rec.Body.String())
}
}
call()
if string(got) != in {
t.Fatalf("corps réécrit sans la clé : %s", got)
}
if err := SetConfigKey("SIDE_SLOT", "on"); err != nil {
t.Fatal(err)
}
stubSideProps(t, 2, 32768)
call()
if slotOf(t, string(got)) != 1 || !strings.Contains(string(got), "12345678901234567890") {
t.Fatalf("corps routé : %s", got)
}
}
// Deux slots de SIDE_SLOT : le préchauffage vise le slot de la discussion.
// Deux slots sans la clé : toujours rien.
func TestSideSlotPrewarmSurLeSlot0(t *testing.T) {
m := prewarmSetup(t, map[string]string{"PREWARM": "on", "SIDE_SLOT": "on", "CTX": "8192"})
c := newTestConv()
prewarmNoteCaps(convActiveID(), Caps{Agent: true})
c.mu.Lock()
c.Messages = []Message{um("bonjour"), am("salut")}
c.mu.Unlock()
m.mu.Lock()
m.slots = 2
m.mu.Unlock()
stubSideProps(t, 2, 4096) // lancement refusé : fenêtre partagée
c.prewarm(prewarmTurnEnd)
if n := len(m.completions()); n != 0 {
t.Fatalf("%d préchauffage(s) sans second slot en service", n)
}
stubSideProps(t, 2, 8192)
c.prewarm(prewarmTurnEnd)
got := m.completions()
if len(got) != 1 {
t.Fatalf("%d préchauffage(s), attendu 1", len(got))
}
if s := slotOf(t, got[0]); s != 0 {
t.Fatalf("id_slot = %d, attendu 0", s)
}
}
+322
View File
@@ -0,0 +1,322 @@
package loki
import (
"bytes"
"context"
"encoding/json"
"fmt"
"hash/fnv"
"io"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// SLOT_PERSIST, côté process web : garder l'état du slot de la discussion
// avant une bascule de preset, le recharger au retour (backend_serve_persist.go
// pour la clé et les refus au lancement).
//
// Sauver : seulement si la dernière requête de Loki passée par le slot 0 était
// une étape d'un tour de la discussion (le tampon d'engineMarkMain) — un
// vérificateur, une tâche, un titre ou un client /v1 passés depuis, et le slot
// ne porte plus ce qu'on croit. Ni brouillon spéculatif actif, ni état trop
// gros (plafond fixe, place libre vérifiée), ni contexte trop court pour valoir
// une écriture. Le fichier est écrit sous un nom provisoire et ne prend son
// nom définitif que si aucune requête n'est partie pendant l'écriture.
//
// Recharger : avant la première requête de la discussion sur le moteur relancé,
// et seulement si le fichier porte la clé de CE moteur (même modèle, même
// build, mêmes réglages — tout ce qui touche au cache) et l'empreinte de CETTE
// discussion, que le slot 0 n'a encore servi aucune tâche (/slots sans
// id_task) et qu'aucun brouillon spéculatif n'y tourne. Une seule chance : le
// fichier est retiré après la tentative, réussie ou non. Toute erreur retombe
// en silence sur un calcul normal — llama.cpp vide lui-même le slot sur un
// rechargement raté.
//
// Sur un modèle hybride (Qwen3.5/3.6), le fichier n'a pas de points de reprise
// : il ne sert que si la requête suivante prolonge exactement les jetons
// gardés, ce qui suppose un gabarit qui rend le dernier tour à l'identique.
// slotPersistMaxBytes : au-delà, l'état n'est pas gardé. Un 27B dense en f16 à
// 32k jetons pèse 8 Gio ; plus gros, l'écriture et la relecture coûtent autant
// que le recalcul sur un disque lent.
const slotPersistMaxBytes = 8 << 30
// slotPersistMinTokens : en dessous, le recalcul est trop court pour valoir une
// écriture.
const slotPersistMinTokens = 4096
// Bornes des appels au moteur : écrire ou relire 8 Gio sur un disque lent.
var (
slotPersistSaveTimeout = 3 * time.Minute
slotPersistRestoreTimeout = 2 * time.Minute
)
// slotPersistName : le fichier d'une discussion sous une clé de moteur.
func slotPersistName(key, conv string) string {
h := fnv.New64a()
h.Write([]byte(conv))
return fmt.Sprintf("persist-%s-%012x.bin", key, h.Sum64()&0xffffffffffff)
}
func slotPersistDir() string { return filepath.Join(LokiHome(), "slots") }
func slotPersistLog(format string, args ...any) {
fmt.Fprintf(os.Stderr, "[slot] "+format+"\n", args...)
}
// engineMainConv : la discussion dont le slot porte plausiblement le tour
// (engineSlotHolds), et le numéro de cette requête ; vide = aucune.
func engineMainConv() (conv string, seq uint64) {
model, window := engineMainNow()
engineGate.mu.Lock()
defer engineGate.mu.Unlock()
m := engineGate.main
if m.seq == 0 || m.seq != engineGate.seq || m.model != model || m.window != window {
return "", 0
}
return m.conv, m.seq
}
// slotPersistSnap : ce qu'une bascule doit savoir du moteur EN SERVICE, relevé
// avant que la configuration ne change (le modèle et la fenêtre du tampon se
// lisent dans la configuration).
type slotPersistSnap struct {
conv, key string
seq uint64
cfg map[string]string
iso slotIsolator // port et clé d'API du moteur en service
}
// slotPersistPrepare : nil = rien à garder (clé absente, moteur sans clé de
// persistance, slot qui ne porte pas un tour de discussion).
func slotPersistPrepare() *slotPersistSnap {
cfg := ReadConfig()
if !slotPersistOn(cfg) || isExternalConfig(cfg) {
return nil
}
key := readSlotPersistMarker(LokiHome())
if key == "" {
return nil
}
conv, seq := engineMainConv()
if conv == "" {
return nil
}
return &slotPersistSnap{conv: conv, key: key, seq: seq, cfg: cfg, iso: localSlotIsolator(resolveChatEndpoint())}
}
// slotState : ce que /slots dit du slot 0.
type slotState struct {
found, processing, speculative, used bool
tokens int
}
func (s slotIsolator) slot0(ctx context.Context) (slotState, error) {
var slots []map[string]json.RawMessage
if err := s.get(ctx, "/slots", &slots); err != nil {
return slotState{}, err
}
for _, sl := range slots {
var id int
if json.Unmarshal(sl["id"], &id) != nil || id != 0 {
continue
}
st := slotState{found: true}
_ = json.Unmarshal(sl["is_processing"], &st.processing)
_ = json.Unmarshal(sl["speculative"], &st.speculative)
_ = json.Unmarshal(sl["n_prompt_tokens"], &st.tokens)
_, st.used = sl["id_task"]
return st, nil
}
return slotState{}, nil
}
// action : POST /slots/0?action=…, borné par timeout. Rend le nombre d'octets
// écrits ou lus d'après la réponse (0 = non dit).
func (s slotIsolator) action(ctx context.Context, action, filename string, timeout time.Duration) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
body, _ := json.Marshal(map[string]string{"filename": filename})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.base+"/slots/0?action="+action, bytes.NewReader(body))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
s.auth(req.Header.Set)
resp, err := s.client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("%s : %s %s", action, resp.Status, strings.TrimSpace(string(raw[:min(len(raw), 300)])))
}
var out struct {
Written int64 `json:"n_written"`
Read int64 `json:"n_read"`
}
_ = json.Unmarshal(raw, &out)
return max(out.Written, out.Read), nil
}
func localSlotIsolator(ep chatEndpoint) slotIsolator {
return slotIsolator{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: ep.auth, client: http.DefaultClient}
}
// slotPersistSize : slotPersistEstimate, remplaçable par les tests.
var slotPersistSize = slotPersistEstimate
// slotPersistEstimate : octets de l'état de tokens jetons sous cfg ; 0 =
// inconnu (et l'état n'est alors pas gardé : on ne borne pas l'inconnu).
func slotPersistEstimate(cfg map[string]string, tokens int) int64 {
p, err := resolveServeModelPath(strings.TrimSpace(cfg["MODEL"]))
if err != nil {
return 0
}
g, err := ggufMeta(p)
if err != nil {
return 0
}
argEnv := map[string]string{}
for _, k := range fidelityArgEnv {
if v := os.Getenv(k); v != "" {
argEnv[k] = v
}
}
kt, vt, _ := effectiveKVTypes(cfg, splitArgs(cfg["EXTRA_ARGS"]), argEnv)
perTok, rec := ggufStateBytes(g, kt, vt)
if perTok <= 0 {
return 0
}
return int64(math.Ceil(perTok*float64(tokens))) + rec
}
// save garde l'état du slot de la discussion, juste avant l'arrêt du moteur.
// Jamais d'erreur rendue : au pire, rien n'est gardé.
func (s *slotPersistSnap) save() {
if s == nil {
return
}
iso := s.iso
ctx := context.Background()
st, err := iso.slot0(ctx)
switch {
case err != nil:
slotPersistLog("état non gardé : /slots illisible (%v)", err)
return
case !st.found || st.processing:
return
case st.speculative:
slotPersistLog("état non gardé : décodage spéculatif actif (le brouillon ne serait pas sauvé)")
return
case st.tokens < slotPersistMinTokens:
return
}
est := slotPersistSize(s.cfg, st.tokens)
if est <= 0 || est > slotPersistMaxBytes {
slotPersistLog("état non gardé : %d jetons, taille estimée %d Mio (plafond %d Mio)", st.tokens, est>>20, slotPersistMaxBytes>>20)
return
}
dir := slotPersistDir()
if free := diskFree(dir); free < 2*est+(1<<30) {
slotPersistLog("état non gardé : place libre insuffisante ou inconnue dans %s", dir)
return
}
name := slotPersistName(s.key, s.conv)
tmp := "tmp-" + name
end, seq := engineRequestBegin(nil)
t0 := time.Now()
n, err := iso.action(ctx, "save", tmp, slotPersistSaveTimeout)
end()
engineGate.mu.Lock()
moved := seq != s.seq+1 || engineGate.seq != seq
engineGate.mu.Unlock()
if err != nil || moved {
_ = os.Remove(filepath.Join(dir, tmp))
if err != nil {
slotPersistLog("état non gardé : %v", err)
}
return
}
if err := os.Rename(filepath.Join(dir, tmp), filepath.Join(dir, name)); err != nil {
_ = os.Remove(filepath.Join(dir, tmp))
slotPersistLog("état non gardé : %v", err)
return
}
slotPersistPrune(dir)
slotPersistLog("état de la discussion gardé : %d jetons, %d Mio en %s", st.tokens, n>>20, time.Since(t0).Round(time.Millisecond))
}
// slotPersistTried : fichiers déjà tentés (nom et date), pour qu'un fichier
// qu'on n'aurait pas pu retirer ne soit pas relu à chaque requête.
var slotPersistTried struct {
mu sync.Mutex
m map[string]bool
}
// slotPersistRestore recharge l'état gardé de conv dans le slot 0, si tout
// concorde. Appelée juste avant une requête de la discussion vers le moteur
// local ; sans la clé, une lecture de configuration et rien d'autre.
func slotPersistRestore(ep chatEndpoint, conv string) {
if ep.External || conv == "" {
return
}
cfg := ReadConfig()
if !slotPersistOn(cfg) || isExternalConfig(cfg) {
return
}
key := readSlotPersistMarker(LokiHome())
if key == "" {
return
}
name := slotPersistName(key, conv)
path := filepath.Join(slotPersistDir(), name)
fi, err := os.Stat(path)
if err != nil {
return
}
// Un fichier, une tentative : le même nom réécrit par une bascule suivante
// (A→B→A→B→A) est un autre fichier.
once := fmt.Sprintf("%s|%d", name, fi.ModTime().UnixNano())
iso := localSlotIsolator(ep)
ctx := context.Background()
st, err := iso.slot0(ctx)
if err != nil || !st.found {
return // moteur pas encore prêt : la requête suivante retentera
}
slotPersistTried.mu.Lock()
if slotPersistTried.m == nil {
slotPersistTried.m = map[string]bool{}
}
tried := slotPersistTried.m[once]
slotPersistTried.m[once] = true
slotPersistTried.mu.Unlock()
if tried {
return
}
// Une seule chance, quoi qu'il arrive.
defer os.Remove(path)
switch {
case st.used || st.processing:
slotPersistLog("état gardé ignoré : le slot a déjà servi depuis le lancement")
return
case st.speculative:
slotPersistLog("état gardé ignoré : décodage spéculatif actif")
return
}
end, _ := engineRequestBegin(nil)
t0 := time.Now()
n, err := iso.action(ctx, "restore", name, slotPersistRestoreTimeout)
end()
if err != nil {
slotPersistLog("état gardé non rechargé, calcul normal : %v", err)
return
}
slotPersistLog("état de la discussion rechargé : %d Mio en %s", n>>20, time.Since(t0).Round(time.Millisecond))
}
+442
View File
@@ -0,0 +1,442 @@
package loki
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
)
// persistSI : moteur qui connaît --slot-save-path, dossier prêt, boucle locale.
func persistSI() serveSysInfo {
si := denseGPU()
si.SlotDir = "/data/slots"
return si
}
func TestSlotPersistPlan(t *testing.T) {
loop := map[string]string{"SLOT_PERSIST": "on", "HOST": "127.0.0.1"}
with := func(kv ...string) map[string]string {
m := map[string]string{}
for k, v := range loop {
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)
ok bool
why string
}{
{name: "clé absente", cfg: map[string]string{"HOST": "127.0.0.1"}},
{name: "boucle locale", cfg: loop, ok: true},
{name: "0.0.0.0 avec clé d'API", cfg: with("HOST", "0.0.0.0"), si: func(s *serveSysInfo) { s.APIKey = "k" }, ok: true},
{name: "0.0.0.0 sans clé", cfg: with("HOST", ""), why: "sans clé"},
{name: "SPEC=auto", cfg: with("SPEC", "auto"), why: "spéculatif"},
{name: "brouillon dans EXTRA_ARGS", cfg: loop, extra: []string{"-md", "d.gguf"}, why: "spéculatif"},
{name: "--slot-save-path à la main", cfg: loop, extra: []string{"--slot-save-path", "/x"}, why: "à la main"},
{name: "variable LLAMA_ARG_SLOT_SAVE_PATH", cfg: loop, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SLOT_SAVE_PATH"] = "/x" }, why: "à la main"},
{name: "experts sur CPU", cfg: loop, extra: []string{"--n-cpu-moe", "30"}, why: "CPU"},
{name: "moteur sans le drapeau", cfg: loop, si: func(s *serveSysInfo) { s.Help = helpRecent }, why: "--slot-save-path"},
{name: "dossier indisponible", cfg: loop, si: func(s *serveSysInfo) { s.SlotDir = "" }, why: "dossier"},
{name: "preset externe", cfg: with(extKeyFlag, "1"), why: "externe"},
}
for _, c := range cases {
si := persistSI()
if c.si != nil {
c.si(&si)
}
ok, why := slotPersistPlan(c.cfg, c.extra, si)
if ok != c.ok || !strings.Contains(why, c.why) {
t.Errorf("%s : ok=%v why=%q", c.name, ok, why)
}
}
}
// --slot-save-path : posé pour SLOT_PERSIST même quand l'isolation n'en veut
// pas (cache coupé) ; sans la clé, la règle du lot 1 inchangée.
func TestSlotPersistSlotSaveArgs(t *testing.T) {
si := serveSysInfo{Help: helpCacheRAM, SlotDir: "/data/slots", ArgEnv: map[string]string{}}
base := map[string]string{"HOST": "127.0.0.1", "CACHE_RAM": "0"}
if got := slotSaveArgs(base, nil, si); got != nil {
t.Fatalf("sans la clé : %v", got)
}
on := map[string]string{"HOST": "127.0.0.1", "CACHE_RAM": "0", "SLOT_PERSIST": "on"}
if got := slotSaveArgs(on, nil, si); !reflect.DeepEqual(got, []string{"--slot-save-path", "/data/slots"}) {
t.Fatalf("avec la clé : %v", got)
}
open := map[string]string{"CACHE_RAM": "0", "SLOT_PERSIST": "on"}
if got := slotSaveArgs(open, nil, si); got != nil {
t.Fatalf("0.0.0.0 sans clé : %v", got)
}
}
// Refus : la ligne est celle sans la clé, une note de plus.
func TestSlotPersistRefusLigneInchangee(t *testing.T) {
si := persistSI()
cfg := map[string]string{"CTX": "32768", "SPEC": "auto"}
want, _, wantNotes := buildServeArgs(cfg, nil, "/bin/llama-server", si)
cfg["SLOT_PERSIST"] = "on"
got, _, notes := buildServeArgs(cfg, nil, "/bin/llama-server", si)
if !reflect.DeepEqual(got, want) || len(notes) != len(wantNotes)+1 {
t.Fatalf("refus :\n%v\n%v\n%q", got, want, notes)
}
}
func TestSlotPersistKey(t *testing.T) {
dir := t.TempDir()
bin := filepath.Join(dir, "llama-server")
lib := filepath.Join(dir, "libggml-cuda.so")
model := filepath.Join(dir, "m.gguf")
for _, p := range []string{bin, lib, model} {
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
si := serveSysInfo{Model: model}
args := []string{bin, "-m", model, "-c", "32768", "--api-key", "secret"}
k0 := slotPersistKey(args, nil, si, bin)
if len(k0) != 16 || k0 != slotPersistKey(args, nil, si, bin) {
t.Fatalf("clé instable : %q", k0)
}
if slotPersistKey([]string{bin, "-m", model, "-c", "32768", "--api-key", "autre"}, nil, si, bin) != k0 {
t.Fatal("la clé d'API ne doit pas entrer dans l'empreinte")
}
if slotPersistKey([]string{bin, "-m", model, "-c", "65536"}, nil, si, bin) == k0 {
t.Fatal("CTX ignoré")
}
if slotPersistKey(args, map[string]string{"GGML_CUDA_GRAPH_OPT": "1"}, si, bin) == k0 {
t.Fatal("environnement ignoré")
}
later := time.Now().Add(time.Hour)
for _, p := range []string{lib, model, bin} {
if err := os.Chtimes(p, later, later); err != nil {
t.Fatal(err)
}
k := slotPersistKey(args, nil, si, bin)
if k == k0 {
t.Fatalf("%s modifié, clé inchangée", filepath.Base(p))
}
k0 = k
}
}
func TestSlotPersistFichiers(t *testing.T) {
home := t.TempDir()
dir := prepareSlotDir(home, true)
names := []string{"persist-0123456789abcdef-000000000001.bin", "persist-0123456789abcdef-000000000002.bin",
"persist-fedcba9876543210-000000000003.bin"}
for i, n := range append(append([]string{}, names...), "tmp-"+names[0], "autre.bin") {
p := filepath.Join(dir, n)
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
at := time.Now().Add(time.Duration(i) * time.Minute)
_ = os.Chtimes(p, at, at)
}
prepareSlotDir(home, true)
slotPersistPrune(dir)
left, _ := os.ReadDir(dir)
var got []string
for _, e := range left {
got = append(got, e.Name())
}
if !reflect.DeepEqual(got, names[1:]) {
t.Fatalf("restent %v, attendu les deux plus récents %v", got, names[1:])
}
prepareSlotDir(home, false)
if left, _ := os.ReadDir(dir); len(left) != 0 {
t.Fatalf("clé retirée : %d fichier(s) gardé(s)", len(left))
}
if readSlotPersistMarker(home) != "" {
t.Fatal("marqueur absent : clé vide attendue")
}
writeSlotPersistMarker(home, "0123456789abcdef")
if readSlotPersistMarker(home) != "0123456789abcdef" {
t.Fatal("marqueur illisible")
}
writeSlotPersistMarker(home, "")
if readSlotPersistMarker(home) != "" {
t.Fatal("marqueur non retiré")
}
}
// moteurSlots : faux llama-server pour /slots, save et restore. Un save écrit
// le fichier dans le dossier des slots, comme le vrai.
type moteurSlots struct {
mu sync.Mutex
actions []string
used, spec bool
restoreFail bool
tokens int
}
func (m *moteurSlots) start(t *testing.T, home string) {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
defer m.mu.Unlock()
switch {
case r.URL.Path == "/slots" && r.Method == http.MethodGet:
s := map[string]any{"id": 0, "is_processing": false, "speculative": m.spec, "n_prompt_tokens": m.tokens}
if m.used {
s["id_task"] = 7
}
sendJSON(w, 200, []any{s})
case r.URL.Path == "/slots/0" && r.Method == http.MethodPost:
var body struct {
Filename string `json:"filename"`
}
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &body)
act := r.URL.Query().Get("action")
m.actions = append(m.actions, act+" "+body.Filename)
if act == "save" {
_ = os.WriteFile(filepath.Join(home, "slots", body.Filename), []byte("kv"), 0o600)
sendJSON(w, 200, map[string]any{"n_saved": m.tokens, "n_written": 2 << 20})
return
}
if m.restoreFail {
sendJSON(w, 400, map[string]any{"error": map[string]any{"message": "Unable to restore slot"}})
return
}
sendJSON(w, 200, map[string]any{"n_restored": m.tokens, "n_read": 2 << 20})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
_, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":")
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
}
func (m *moteurSlots) got() []string {
m.mu.Lock()
defer m.mu.Unlock()
return append([]string(nil), m.actions...)
}
const persistTestKey = "0123456789abcdef"
// persistSetup : discussion « c1 » dont le tour vient de passer par le slot.
func persistSetup(t *testing.T, on bool) (*moteurSlots, string) {
t.Helper()
home := testHome(t)
if err := os.MkdirAll(filepath.Join(home, "slots"), 0o700); err != nil {
t.Fatal(err)
}
for k, v := range map[string]string{"MODEL": "m.gguf", "CTX": "32768"} {
if err := SetConfigKey(k, v); err != nil {
t.Fatal(err)
}
}
if on {
if err := SetConfigKey("SLOT_PERSIST", "on"); err != nil {
t.Fatal(err)
}
}
writeSlotPersistMarker(home, persistTestKey)
prev := slotPersistSize
slotPersistSize = func(map[string]string, int) int64 { return 1 << 20 }
reset := func() {
engineGate.mu.Lock()
engineGate.main = engineMainStamp{}
engineGate.mu.Unlock()
}
reset()
t.Cleanup(func() { slotPersistSize = prev; reset() })
m := &moteurSlots{tokens: 20000}
m.start(t, home)
end, seq := engineRequestBegin(nil)
end()
engineMarkMain(seq, "c1")
return m, home
}
// Bascule puis retour : gardé sous la clé du moteur, rechargé une fois avant
// la première requête de la discussion, puis retiré.
func TestSlotPersistSauveEtRecharge(t *testing.T) {
m, home := persistSetup(t, true)
snap := slotPersistPrepare()
if snap == nil {
t.Fatal("rien à garder")
}
snap.save()
name := slotPersistName(persistTestKey, "c1")
path := filepath.Join(home, "slots", name)
if _, err := os.Stat(path); err != nil {
t.Fatalf("état non gardé : %v (%v)", err, m.got())
}
if _, err := os.Stat(filepath.Join(home, "slots", "tmp-"+name)); !os.IsNotExist(err) {
t.Fatal("fichier provisoire resté")
}
ep := resolveChatEndpoint()
slotPersistRestore(ep, "autre") // autre discussion : rien
slotPersistRestore(ep, "c1")
slotPersistRestore(ep, "c1") // une seule fois
want := []string{"save tmp-" + name, "restore " + name}
if got := m.got(); !reflect.DeepEqual(got, want) {
t.Fatalf("appels %v, attendu %v", got, want)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("fichier gardé après la tentative")
}
}
// Sans la clé : ni relevé, ni appel au moteur.
func TestSlotPersistDefautRien(t *testing.T) {
m, home := persistSetup(t, false)
if snap := slotPersistPrepare(); snap != nil {
t.Fatal("relevé sans la clé")
}
var snap *slotPersistSnap
snap.save()
name := slotPersistName(persistTestKey, "c1")
if err := os.WriteFile(filepath.Join(home, "slots", name), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
slotPersistRestore(resolveChatEndpoint(), "c1")
if got := m.got(); len(got) != 0 {
t.Fatalf("appels sans la clé : %v", got)
}
}
// Rien n'est gardé si le slot ne porte plus le tour, si un brouillon tourne,
// si l'état est trop court ou trop gros, ou si une requête part pendant
// l'écriture.
func TestSlotPersistSauvegardeRefusee(t *testing.T) {
m, home := persistSetup(t, true)
end := engineRequestStart() // une autre requête est passée par le slot
end()
if slotPersistPrepare() != nil {
t.Fatal("relevé alors que le slot ne porte plus le tour")
}
for _, c := range []struct {
name string
set func()
}{
{"brouillon spéculatif", func() { m.spec = true }},
{"contexte court", func() { m.tokens = 100 }},
{"état trop gros", func() { slotPersistSize = func(map[string]string, int) int64 { return slotPersistMaxBytes + 1 } }},
} {
m.mu.Lock()
m.spec, m.tokens = false, 20000
m.mu.Unlock()
slotPersistSize = func(map[string]string, int) int64 { return 1 << 20 }
e, seq := engineRequestBegin(nil)
e()
engineMarkMain(seq, "c1")
snap := slotPersistPrepare()
m.mu.Lock()
c.set()
m.mu.Unlock()
snap.save()
if got := m.got(); len(got) != 0 {
t.Fatalf("%s : appels %v", c.name, got)
}
}
// Une requête entre le relevé et l'écriture : fichier jeté.
slotPersistSize = func(map[string]string, int) int64 { return 1 << 20 }
m.mu.Lock()
m.spec, m.tokens = false, 20000
m.mu.Unlock()
e, seq := engineRequestBegin(nil)
e()
engineMarkMain(seq, "c1")
snap := slotPersistPrepare()
e2 := engineRequestStart()
e2()
snap.save()
left, _ := os.ReadDir(filepath.Join(home, "slots"))
if len(left) != 0 {
t.Fatalf("fichier gardé malgré une requête pendant l'écriture : %v", left[0].Name())
}
}
// Rechargement refusé (slot déjà servi, brouillon) ou raté : calcul normal,
// fichier retiré, aucune erreur.
func TestSlotPersistRechargementPrudent(t *testing.T) {
for _, c := range []struct {
name string
set func(*moteurSlots)
restore bool
}{
{"slot déjà servi", func(m *moteurSlots) { m.used = true }, false},
{"brouillon spéculatif", func(m *moteurSlots) { m.spec = true }, false},
{"échec du moteur", func(m *moteurSlots) { m.restoreFail = true }, true},
} {
t.Run(c.name, func(t *testing.T) {
m, home := persistSetup(t, true)
name := slotPersistName(persistTestKey, "c1")
path := filepath.Join(home, "slots", name)
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
c.set(m)
slotPersistRestore(resolveChatEndpoint(), "c1")
if got := m.got(); (len(got) == 1) != c.restore {
t.Fatalf("appels %v", got)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("fichier gardé après la tentative")
}
})
}
}
// Clé du moteur différente (autre build, autres réglages) : le fichier n'est
// même pas cherché.
func TestSlotPersistAutreMoteur(t *testing.T) {
m, home := persistSetup(t, true)
name := slotPersistName(persistTestKey, "c1")
if err := os.WriteFile(filepath.Join(home, "slots", name), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
writeSlotPersistMarker(home, "fedcba9876543210")
slotPersistRestore(resolveChatEndpoint(), "c1")
if got := m.got(); len(got) != 0 {
t.Fatalf("rechargé sous une autre clé : %v", got)
}
}
// SLOT_PERSIST survit à la bascule (A → B sans la clé → A) : sans ça, le
// lancement de B effacerait l'état gardé de A. Un preset qui la pose gagne.
func TestSlotPersistSurvitALaBascule(t *testing.T) {
home := testHome(t)
setConfig(t, "MODEL=a.gguf\nSLOT_PERSIST=on\n")
b := filepath.Join(home, "b.env")
if err := os.WriteFile(b, []byte("MODEL=b.gguf\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := applyPresetFile(b); err != nil {
t.Fatal(err)
}
if got := ReadConfig()["SLOT_PERSIST"]; got != "on" {
t.Fatalf("SLOT_PERSIST = %q après bascule", got)
}
c := filepath.Join(home, "c.env")
if err := os.WriteFile(c, []byte("MODEL=c.gguf\nSLOT_PERSIST=off\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := applyPresetFile(c); err != nil {
t.Fatal(err)
}
if got := ReadConfig()["SLOT_PERSIST"]; got != "off" {
t.Fatalf("le preset qui pose la clé doit gagner : %q", got)
}
}
+106 -2
View File
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
@@ -46,6 +47,12 @@ import (
// repos d'après /slots ;
// - appel synchrone, borné à 2 s, sous le verrou du compteur : aucune
// requête de Loki ne peut partir entre la vérification et l'effacement.
//
// Avec SIDE_SLOT (deux slots, llm_sideslot.go), rien n'est jamais effacé : le
// garde-fou du slot unique s'abstient, et les travaux annexes ne passent de
// toute façon plus par le slot de la discussion. Pour la même raison, leurs
// requêtes n'avancent pas le numéro qui dit si le slot 0 porte encore le tour
// (engineSlotHolds) : COMPACT_CONTINUATION reste possible après eux.
// slotEraseMinBuild : premier build de llama.cpp qui recharge un slot vide
// depuis le cache de prompts (PR #20993, 50e0ad08fb).
@@ -58,6 +65,57 @@ var engineGate struct {
mu sync.Mutex
inflight int
served uint64 // réponses 200 du moteur obtenues par runChat ou le bench
// seq numérote les requêtes de Loki vers le moteur local, préchauffage
// compris. main : la dernière, quand c'était une étape d'un tour de
// discussion acceptée par le moteur — le slot porte alors, selon toute
// vraisemblance, le prompt de cette discussion (COMPACT_CONTINUATION).
seq uint64
main engineMainStamp
}
// engineMainStamp : la requête d'un tour de discussion que le slot porte.
// Modèle et fenêtre en font partie : un moteur relancé sur un autre modèle ou
// une autre fenêtre a un slot vide.
type engineMainStamp struct {
seq uint64
conv, model string
window int
}
// engineMainNow : modèle et fenêtre du moteur, pour le tampon.
func engineMainNow() (model string, window int) {
return filepath.Base(strings.TrimSpace(ReadConfig()["MODEL"])), ctxWindow()
}
// engineMarkMain : la requête seq, étape d'un tour de la discussion conv, a été
// acceptée par le moteur. Sans effet si une autre requête est partie depuis.
func engineMarkMain(seq uint64, conv string) {
if conv == "" {
return
}
model, window := engineMainNow()
engineGate.mu.Lock()
defer engineGate.mu.Unlock()
if engineGate.seq == seq {
engineGate.main = engineMainStamp{seq: seq, conv: conv, model: model, window: window}
}
}
// engineSlotHolds : le slot porte-t-il plausiblement le prompt de conv ? Oui si
// la dernière requête de Loki vers le moteur était une étape d'un de ses tours,
// acceptée, sur le même modèle et la même fenêtre. Un vérificateur, un
// sous-agent, une tâche, un préchauffage, un résumé, un bench, un client /v1 ou
// une autre discussion passés depuis : non. Un client qui parlerait au moteur
// sans passer par Loki échappe à ce compte — au pire, un recalcul.
func engineSlotHolds(conv string) bool {
if conv == "" {
return false
}
model, window := engineMainNow()
engineGate.mu.Lock()
defer engineGate.mu.Unlock()
m := engineGate.main
return m.seq != 0 && m.seq == engineGate.seq && m.conv == conv && m.model == model && m.window == window
}
// engineServed note qu'une requête de Loki a été acceptée par le moteur local :
@@ -82,10 +140,40 @@ var sideJobs atomic.Int32
// engineRequestStart marque une requête vers le moteur local ; la fonction
// rendue la clôt (une seule fois, quel que soit le nombre d'appels), une fois
// le corps de la réponse lu.
func engineRequestStart() func() {
// le corps de la réponse lu. Un préchauffage en vol (PREWARM, chat_prewarm.go)
// est annulé d'abord : il ne passe jamais devant un vrai travail.
func engineRequestStart() func() { return engineRequestStartKeep(nil) }
// engineRequestStartKeep : engineRequestStart, sauf pour le préchauffage en vol
// que keep reconnaît comme le début exact de cette requête — il continue, et la
// requête le suit dans le même slot sur le préfixe qu'il vient de calculer.
// Décidé sous le verrou du compteur : aucun préchauffage ne peut démarrer entre
// la décision et le compte.
func engineRequestStartKeep(keep func(*prewarmRun) bool) func() {
end, _ := engineRequestBegin(keep)
return end
}
// engineRequestBegin : engineRequestStartKeep, avec le numéro de la requête
// (engineMarkMain).
func engineRequestBegin(keep func(*prewarmRun) bool) (func(), uint64) {
return engineRequestBeginSide(keep, false)
}
// engineRequestBeginSide : engineRequestBegin pour une requête qui part, si
// side, sur le second slot de SIDE_SLOT (id_slot 1). Elle ne touche pas au slot
// de la discussion : le numéro n'avance pas, et engineSlotHolds dit toujours
// vrai après elle. Comptée en vol comme les autres.
func engineRequestBeginSide(keep func(*prewarmRun) bool, side bool) (func(), uint64) {
engineGate.mu.Lock()
if p := prewarmCur; p != nil && (keep == nil || !keep(p)) {
prewarmDropLocked(p)
}
engineGate.inflight++
if !side {
engineGate.seq++
}
seq := engineGate.seq
engineGate.mu.Unlock()
var once sync.Once
return func() {
@@ -94,7 +182,23 @@ func engineRequestStart() func() {
engineGate.inflight--
engineGate.mu.Unlock()
})
}, seq
}
// engineProxyBegin : engineRequestBeginSide pour une requête d'un client passée
// par les proxys de Loki (/v1, /health, /props, /metrics, /slots). Comptée en
// vol dans tous les cas, comme avant. Une lecture (GET, HEAD : /v1/models, une
// sonde /health d'Open WebUI…) ne calcule rien dans aucun slot : elle ne doit
// ni annuler un préchauffage (PREWARM), ni dire que le slot ne porte plus la
// discussion (COMPACT_CONTINUATION, SLOT_PERSIST). Sans ces clés, aucune
// différence.
func engineProxyBegin(r *http.Request, side bool) func() {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
end, _ := engineRequestBeginSide(func(*prewarmRun) bool { return true }, true)
return end
}
end, _ := engineRequestBeginSide(nil, side)
return end
}
// slotsWrite : une action sur /slots (save, restore, erase) plutôt qu'une
+37 -10
View File
@@ -33,20 +33,42 @@ const memIndexPrefix = "Project memory index"
// proactif ou si l'index est vide. On n'injecte que l'INDEX (titres + accroches),
// jamais le contenu des pages — l'IA fait mem_read pour lire une page.
func memIndexMessage() (Message, bool) {
if memMode() != MemAlways {
idx, ok := memIndexText()
if !ok {
return Message{}, false
}
return renderMemIndex(idx, ""), true
}
// memIndexText lit l'index à injecter : ok=false hors mode mémoire proactif ou
// sans aucune page. Séparé du rendu pour que le bloc figé (chat_projsnap.go)
// déduise son état de la MÊME lecture que le texte qu'il envoie.
func memIndexText() (string, bool) {
if memMode() != MemAlways {
return "", false
}
idx := strings.TrimSpace(MemContent(memIndexFile))
// Un index qui n'a que son en-tête ne dit rien au modèle et coûte du contexte
// à chaque tour : on attend au moins une VRAIE ligne de page. Le test est fait
// sur le début de ligne, parce que l'en-tête cite le format `- [titre](…)` en
// exemple — une simple recherche du motif le prendrait pour une page.
if idx == "" || !hasIndexedPage(idx) {
return Message{}, false
return "", false
}
content := memIndexPrefix + " for project \"" + projectName(activeProjectSlug()) +
"\" — the pages you can open with mem_read (only titles/hooks here, not their content). Auto-maintained.\n\n" + idx
return Message{Role: "system", Content: content}, true
return idx, true
}
// renderMemIndex : le message d'index. asOf vide = la forme vivante, à jour à
// chaque tour (texte d'avant PROJ_SNAPSHOT, à l'octet près) ; sinon la copie
// figée, datée, que les <context_update> de Loki complètent ensuite.
func renderMemIndex(idx, asOf string) Message {
head := memIndexPrefix + " for project \"" + projectName(activeProjectSlug()) + "\""
tail := " — the pages you can open with mem_read (only titles/hooks here, not their content). Auto-maintained."
if asOf != "" {
head += " as of " + asOf
tail += memIndexFrozenTail
}
return Message{Role: "system", Content: head + tail + "\n\n" + idx}
}
// projectContextPrefix marque le message de contexte projet injecté (description),
@@ -59,14 +81,18 @@ const projectContextPrefix = "Project context"
// dans quel projet elle est via l'index mémoire, sans laïus. Indépendant du mode
// mémoire — une description reste utile même mémoire coupée.
func projectContextMessage() (Message, bool) {
slug := activeProjectSlug()
desc := strings.TrimSpace(projectDesc(slug))
desc := strings.TrimSpace(projectDesc(activeProjectSlug()))
if desc == "" {
return Message{}, false
}
return renderProjectContext(desc), true
}
// renderProjectContext : le message de description, pour une description non vide.
func renderProjectContext(desc string) Message {
content := projectContextPrefix + " — you are working within the project \"" +
projectName(slug) + "\". What this project is about (set by the user):\n\n" + desc
return Message{Role: "system", Content: content}, true
projectName(activeProjectSlug()) + "\". What this project is about (set by the user):\n\n" + desc
return Message{Role: "system", Content: content}
}
// projectSystemMessages renvoie, dans l'ordre, tout ce qui situe l'IA dans son
@@ -82,7 +108,8 @@ func projectContextMessage() (Message, bool) {
//
// Le prix est une invalidation du cache de prompt quand l'index change, c'est-à-
// dire quand une page est créée ou supprimée : exactement les moments où le
// modèle DOIT voir la nouvelle liste.
// modèle DOIT voir la nouvelle liste. PROJ_SNAPSHOT=on (chat_projsnap.go) évite
// ce prix pour la discussion : bloc figé, changements livrés en <context_update>.
func projectSystemMessages() []Message {
var out []Message
if m, ok := projectContextMessage(); ok {
+21 -1
View File
@@ -37,6 +37,7 @@ const (
perfCompact = "compact" // résumé de compaction
perfBench = "bench" // mesure du moteur (cache_prompt:false)
perfForeign = "foreign" // client externe passé par /v1 : vu, pas mesuré
perfPrewarm = "prewarm" // préchauffage du cache (PREWARM) : réponse jetée
)
type perfCtxKey struct{}
@@ -452,6 +453,12 @@ type perfKindSum struct {
LostEvents int `json:"lost_events"`
LostTokens int `json:"lost_tokens"`
TTFTMedianMs *float64 `json:"ttft_median_ms,omitempty"`
// Acceptation du brouillon par nature : un n-gramme paie en mode code
// (chemins, diffs recopiés) et presque pas en prose — le taux global
// mélangerait les deux. Absent = moteur muet ou spéculation coupée.
DraftN int `json:"draft_n,omitempty"`
DraftAccepted int `json:"draft_accepted,omitempty"`
DraftRate *float64 `json:"draft_rate,omitempty"`
}
type perfDepthSum struct {
@@ -472,6 +479,9 @@ type perfSummary struct {
DraftN int `json:"draft_n"`
DraftAccepted int `json:"draft_accepted"`
DraftRate *float64 `json:"draft_rate,omitempty"`
// Template : dernier verdict de la sonde de gabarit (chat_tplprobe.go),
// absent tant qu'aucune n'a tourné ou sur un preset externe.
Template *tplProbeResult `json:"template,omitempty"`
}
var perfDepthBuckets = []struct {
@@ -546,8 +556,10 @@ func perfSummarize(recs []perfRec) perfSummary {
}
if r.DraftN != nil {
s.DraftN += *r.DraftN
k.DraftN += *r.DraftN
if r.DraftAcc != nil {
s.DraftAccepted += *r.DraftAcc
k.DraftAccepted += *r.DraftAcc
}
}
}
@@ -557,6 +569,10 @@ func perfSummarize(recs []perfRec) perfSummary {
k.PrefillSecPerTurn = k.PrefillSec / float64(k.Turns)
}
k.TTFTMedianMs = perfMedian(ttft[kind])
if k.DraftN > 0 {
r := float64(k.DraftAccepted) / float64(k.DraftN)
k.DraftRate = &r
}
}
if sumTotal > 0 {
r := float64(sumCached) / float64(sumTotal)
@@ -580,5 +596,9 @@ func handlePerfSummary(w http.ResponseWriter, r *http.Request) {
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET seulement"})
return
}
sendJSON(w, http.StatusOK, perfSummarize(perfLog.snapshot()))
s := perfSummarize(perfLog.snapshot())
if r, ok := tplCapsCurrent(); ok {
s.Template = &r
}
sendJSON(w, http.StatusOK, s)
}
+7
View File
@@ -249,6 +249,13 @@ func TestPerfSummarize(t *testing.T) {
if s.DraftRate == nil || *s.DraftRate != 0.75 {
t.Fatalf("acceptation : %v", s.DraftRate)
}
// Par nature aussi : le sous-agent sans brouillon n'a pas de taux inventé.
if m.DraftRate == nil || *m.DraftRate != 0.75 || m.DraftN != 40 || m.DraftAccepted != 30 {
t.Fatalf("acceptation main : %+v", m)
}
if sa := s.Kinds[perfSubagent]; sa.DraftRate != nil || sa.DraftN != 0 {
t.Fatalf("acceptation sous-agent : %+v", sa)
}
if len(s.Depth) != 4 || s.Depth[0].PPMedianTPS == nil || *s.Depth[0].PPMedianTPS != 500 || s.Depth[2].TGMedianTPS == nil || *s.Depth[2].TGMedianTPS != 30 || s.Depth[3].N != 0 {
t.Fatalf("profondeurs : %+v", s.Depth)
}
+6 -1
View File
@@ -525,7 +525,12 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
http.Error(w, "/slots est en lecture seule via le relais", http.StatusMethodNotAllowed)
return
}
defer engineRequestStart()() // voir oaiHandler
side, status, msg := sideSlotProxyRewrite(r) // voir oaiHandler
if status != 0 {
http.Error(w, msg, status)
return
}
defer engineProxyBegin(r, side)() // voir oaiHandler
lp.ServeHTTP(w, r)
return
}
+10
View File
@@ -96,6 +96,8 @@ func Main() {
mustExit(cmdTest(args))
case "bench":
mustExit(cmdBench(args))
case "tune":
mustExit(cmdTune(args))
case "llamacpp":
mustExit(cmdLlamacpp(args))
case "update":
@@ -136,6 +138,14 @@ Moteur (loki-engine) :
edit éditer la configuration dans $EDITOR
switch [N] activer un preset de presets/ (interactif ou par numéro)
test | bench [N] [--full] vérifier que l'IA répond / mesurer prefill + decode (--full : en profondeur)
tune [--placement] [--opt-in] [--budget MIN] [--stages lots,threads…]
optimiseur sans perte : essais (lots, threads, marges --fit…)
sur un moteur privé, mesurés au bench complet ; rien n'est
écrit sans ta réponse (copie du preset ou preset actuel,
vérifié puis rétabli en cas d'échec). --placement : --fit à la
place des experts / --tensor-split placés à la main (réécrit
EXTRA_ARGS) ; --opt-in : SPEC, CUDA_GRAPH_OPT, --backend-sampling
--stages : marges,lots,delestage,threads,files (défaut : toutes)
vram utilisation GPU/VRAM (nvidia-smi)
gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous)
set-api-key [clé] protéger llama-server (clé Bearer); vide = générer, "" = retirer
+52
View File
@@ -0,0 +1,52 @@
//go:build linux
package loki
import (
"os"
"strconv"
"strings"
)
// procIdentity : instant de démarrage (en tops d'horloge depuis le boot, champ
// 22 de /proc/<pid>/stat) et binaire d'un processus vivant. Un PID recyclé
// après un redémarrage de Loki a un autre instant de démarrage : l'optimiseur ne
// tue un essai orphelin que si les deux concordent (backend_tune_lock.go).
//
// L'instant est relatif au boot : après une coupure de courant, un service
// lancé au même moment du démarrage peut retrouver le même PID ET le même top,
// et un verrou laissé par l'ancien passerait pour vivant — tout serait refusé
// jusqu'à son retrait à la main. L'identifiant du boot le préfixe donc.
func procIdentity(pid int) (start, exe string, ok bool) {
if pid <= 0 {
return "", "", false
}
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
if err != nil {
return "", "", false
}
// Le nom du processus (champ 2, entre parenthèses) peut contenir des blancs
// et des parenthèses : on repart de la DERNIÈRE parenthèse fermante.
s := string(b)
i := strings.LastIndexByte(s, ')')
if i < 0 {
return "", "", false
}
f := strings.Fields(s[i+1:])
// f[0] est le champ 3 (état) : le champ 22 est donc f[19]. Un zombie a fini
// de tourner, il ne compte plus comme vivant.
if len(f) < 20 || f[0] == "Z" {
return "", "", false
}
exe, _ = os.Readlink("/proc/" + strconv.Itoa(pid) + "/exe")
// Binaire remplacé par une mise à jour pendant que le processus tourne : le
// lien porte « (deleted) », c'est pourtant bien le même processus.
exe = strings.TrimSuffix(exe, " (deleted)")
start = f[19]
if b, err := os.ReadFile("/proc/sys/kernel/random/boot_id"); err == nil {
if id := strings.TrimSpace(string(b)); id != "" {
start = id + "/" + start
}
}
return start, exe, true
}
+37
View File
@@ -0,0 +1,37 @@
//go:build !linux && !windows
package loki
import (
"os"
"os/exec"
"strconv"
"strings"
)
// procIdentity : instant de démarrage et binaire d'un processus vivant, lus par
// ps (macOS, BSD). Voir la version Linux pour le pourquoi.
func procIdentity(pid int) (start, exe string, ok bool) {
if pid <= 0 || !processAlive(pid) {
return "", "", false
}
p := strconv.Itoa(pid)
// lstart passe par strftime(%c) dans la langue et le fuseau de l'appelant :
// le processus web (launchd, sans LANG) et un terminal en français ne liraient
// pas la même chaîne pour le même processus, et un verrou vivant passerait
// pour périmé. Langue et fuseau fixés.
cmd := exec.Command("ps", "-o", "lstart=", "-p", p)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C", "TZ=UTC")
out, err := cmd.Output()
if err != nil {
return "", "", false
}
start = strings.Join(strings.Fields(string(out)), " ")
if start == "" {
return "", "", false
}
if out, err := exec.Command("ps", "-o", "comm=", "-p", p).Output(); err == nil {
exe = strings.TrimSpace(string(out))
}
return start, exe, true
}
+71 -5
View File
@@ -17,6 +17,23 @@ import (
//
// editConfig and showVram live here because they work the same everywhere.
// serviceAction : le seul passage vers serviceActionOS pour le reste de Loki.
// Pendant une optimisation (backend_tune_lock.go), le moteur d'essai tient la
// VRAM : démarrer le vrai moteur à côté — bascule de preset, GPU, mise à jour,
// rechargement… — chargerait deux modèles. Refus, quel que soit l'appelant et la
// plateforme ; l'optimiseur, lui, appelle serviceActionOS directement. Un verrou
// périmé (optimisation tuée) est écarté AVANT tout démarrage, son essai
// orphelin arrêté : sinon le moteur relancé partagerait la carte avec lui.
func serviceAction(action string) error {
if action == "start" || action == "restart" {
if err := tuneGuard(); err != nil {
return err
}
tuneReapStale()
}
return serviceActionOS(action)
}
// preflightEngine vérifie ce sans quoi le moteur ne PEUT pas démarrer, avant de
// lancer le service. Sinon llama-server sortait en erreur, systemd le relançait
// toutes les 3 s, et `loki start` affichait un « activating » rassurant pendant
@@ -74,8 +91,17 @@ var configTemplate = []struct{ key, help string }{
{"THREADS_BATCH", "threads CPU du prefill et de la vérification spéculative (MTP) ; vide ou 0 = comme THREADS"},
{"FIT_TARGET", "Mio laissés libres par carte par le placement auto (--fit-target), ex. 1024,3072 dans l'ordre des cartes ; " +
"vide = 1024, minimum 1024 ; sans effet si NGL chiffré, --tensor-split, -ot ou --n-cpu-moe"},
{"LOAD_GUARD", "off = lancer quand même un --load-mode none/mlock/dio/mmap+mlock (ou --no-mmap, --mlock) dont les poids " +
"restés en RAM dépassent à coup sûr 90 % de la RAM (limite du conteneur comprise) ; vide (défaut) = refus clair au " +
"lancement dans ce cas, simple avertissement au-delà de 80 % estimés"},
{"OP_OFFLOAD_MIN_BATCH", "experts MoE sur CPU : taille de lot à partir de laquelle ils sont recopiés vers le GPU ; " +
"vide = défaut du moteur (32) ; plus haut = lots courts sur CPU, à mesurer"},
{"SPLIT_MODE", "tensor = parallélisme de tenseurs entre cartes CUDA (-sm tensor, expérimental) : décodage plus rapide, " +
"prefill plus lent, à mesurer par tour. Loki pose -ngl all, -ts au prorata de la VRAM, -fa on (sauf réglés à la main) " +
"et GGML_CUDA_ALLREDUCE=internal (NCCL compresse en BF16, avec perte). Refusé sans la liste {none,layer,row,tensor} " +
"dans l'aide du moteur, avec un cache KV autre que f16/bf16/f32, des poids ou le KV sur CPU, --backend-sampling, " +
"-fa off, -sm à la main, SPEC, SIDE_SLOT, CTX non chiffré, une seule carte ou une VRAM estimée insuffisante. " +
"Vide/off (défaut) = découpe par couches"},
{"CUDA_GRAPH_OPT", "on = GGML_CUDA_GRAPH_OPT=1, branches Q/K/V en parallèle au décodage (expérimental, " +
"sortie identique, gain de 0 à quelques %) ; off puis redémarrage s'il plante"},
{"KV_TYPE", "quantization du cache KV ; vide = f16, sorties de référence ; q8_0 les modifie légèrement, " +
@@ -83,19 +109,55 @@ var configTemplate = []struct{ key, help string }{
{"CACHE_RAM", "cache de prompts en RAM hôte (--cache-ram, Mio), copie exacte des conversations quittées, jamais la VRAM ; " +
"vide = auto (agrandi seulement pour un modèle tout-GPU), -1 = sans limite, 0 = coupé"},
{"CACHE_ISOLATE", "off = ne plus effacer le slot après un sous-agent, une vérification, une tâche ou un bench"},
{"SIDE_SLOT", "on = second slot pour les travaux annexes : le moteur ouvre 2 slots de CTX jetons chacun (-c 2×CTX, " +
"--parallel 2, cache KV non unifié : aucun slot ne déborde sur l'autre), la discussion garde le slot 0, vérification, " +
"sous-agents, tâches, bench, résumé sur transcription et clients /v1 passent par le 1 (id_slot) — l'état de la " +
"discussion ne bouge plus. Cache KV et état récurrent en double en VRAM : refusé si le compte ne tient pas, si des " +
"poids sont sur CPU, ou si PARALLEL≠2, -c, -np, -kvu, --cache-idle-slots… sont réglés à la main. Vide/off (défaut) = un slot"},
{"SLOT_PERSIST", "on = garder sur disque (LOKI_HOME/slots, 2 fichiers au plus, 8 Gio chacun au plus) l'état du slot de " +
"la discussion à la bascule de preset, et le recharger au retour avant son premier message : moteur, modèle et réglages " +
"identiques à l'octet près (empreinte), slot encore vierge, sinon calcul normal. Refusé avec le décodage spéculatif, " +
"des poids sur CPU, ou un moteur joignable sans clé d'API hors 127.0.0.1. Jamais à travers une mise à jour du moteur. " +
"Réglage de machine, gardé à la bascule. Vide/off (défaut) = rien n'est écrit"},
{"CTX_CHECKPOINTS", "points de reprise par slot d'un modèle hybride (--ctx-checkpoints) ; vide = défaut du moteur (32) ; " +
"chacun pèse 70 à 200 Mio de RAM hôte : 8 à 16 si le modèle remplit déjà la RAM"},
{"CKPT_MIN_STEP", "espacement minimal en jetons entre deux points de reprise (--checkpoint-min-step), > 0 ; " +
"vide = défaut du moteur, ou 2048 d'office sur un hybride avec un moteur officiel antérieur à b10864"},
{"SPEC", "décodage spéculatif, sortie inchangée : off (défaut) / auto = tête MTP du modèle ou MODEL_DRAFT si --fit place " +
"tout et qu'aucun essai n'a échoué / mtp = imposé ; ~1-2 Go de VRAM en plus"},
{"MODEL_DRAFT", "tête MTP publiée à part (mtp-*.gguf) ou petit modèle brouillon, nom ou chemin comme MODEL ; utilisé si SPEC≠off"},
"tout et qu'aucun essai n'a échoué / mtp = imposé ; ~1-2 Go de VRAM en plus / ngram = n-grammes du contexte " +
"(ngram-mod 24/48/64, sans brouillon, utile en mode code, à mesurer ; refusé avec des poids sur CPU sauf " +
"OP_OFFLOAD_MIN_BATCH ≥ 66) / mtp+ngram = les deux, n-grammes seuls si pas de tête MTP"},
{"MODEL_DRAFT", "tête MTP publiée à part (mtp-*.gguf) ou petit modèle brouillon, nom ou chemin comme MODEL ; utilisé avec SPEC=auto, mtp ou mtp+ngram"},
{"SPEC_N_MAX", "jetons anticipés par étape (--spec-draft-n-max) ; vide = défaut du moteur (3)"},
{"SPEC_SAMPLING", "tirage du brouillon : greedy (défaut, exact) ; probabilistic seulement avec SPEC=mtp"},
{"SPEC_SAMPLING", "tirage du brouillon : greedy (défaut, exact) ; probabilistic seulement avec SPEC=mtp ou mtp+ngram"},
{"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"},
{"REASONING_PRESERVE", "on/off = garder ou non la réflexion des tours passés dans le gabarit (--reasoning-preserve) ; " +
"vide = défaut du moteur (on depuis b10763). Loki ne renvoie pas cette réflexion : off rend l'ancien historique " +
"aux gabarits type Qwen3.6, mais change celui des gabarits qui la gardent d'eux-mêmes (Qwen3.8)"},
"vide = défaut du moteur (on depuis b10763). Sans REASONING_ECHO, Loki ne renvoie pas cette réflexion : off rend " +
"l'ancien historique aux gabarits type Qwen3.6, mais change celui des gabarits qui la gardent d'eux-mêmes (Qwen3.8). " +
"Avec REASONING_ECHO=on, on = préfixe stable d'un message à l'autre (mode entraîné mais optionnel selon la fiche " +
"Qwen3.6), au prix de plus de contexte par tour, donc d'une compaction plus tôt"},
{"REASONING_ECHO", "on = renvoyer au moteur local le raisonnement du modèle (reasoning_content), au format entraîné : " +
"les étapes d'une boucle d'outils se relisent avec leur réflexion au lieu de blocs vides, et le moteur ne recalcule " +
"plus le dernier message ; plus de contexte par tour, compaction plus tôt. Vide/off (défaut) = rien n'est renvoyé. " +
"Jamais vers une API externe ni vers un autre modèle ; suspendu de lui-même si le gabarit le refuse"},
{"PROJ_SNAPSHOT", "on = figer le bloc projet (description, index mémoire, trackers, AGENTS.md) par discussion et livrer " +
"ses changements en <context_update> en tête du message suivant : une page créée ou une valeur de tracker ne fait plus " +
"recalculer toute la conversation. Repris tout neuf à chaque compaction, redémarrage ou changement de modèle/projet. " +
"Vide/off (défaut) = bloc reconstruit à chaque tour ; sans effet sur un preset externe"},
{"PREWARM", "on = préparer le prochain tour pendant que tu lis : après un tour (compaction comprise) ou une tâche, " +
"Loki envoie au moteur local la requête suivante avec un message « . » et 1 jeton de réponse, jetés ; le vrai message " +
"ne calcule plus que lui-même. Annulé dès qu'une autre requête part, sauf si elle prolonge exactement ce préfixe ; " +
"jamais avec plus d'un slot (sauf les deux de SIDE_SLOT : slot de la discussion). full = aussi au changement de discussion " +
"(après 3 s). Vide/off (défaut) = aucune requête de plus"},
{"KEEP_TURN_IMAGES", "zone grise — on = garder dans l'historique les images montrées par les outils (captures, see_image), " +
"rangées par référence, au lieu de les oublier en fin de tour : le tour suivant ne recalcule plus la boucle d'outils " +
"depuis la première capture. Coût mesuré par le moteur, total borné à 10 % de la fenêtre, retirées d'abord à toute " +
"compaction (qui arrive donc plus tôt) ; vision active seulement, preset externe s'il déclare la vision (images " +
"refacturées à chaque tour). Vide/off (défaut) = images éphémères, requête inchangée"},
{"NUDGE_IN_TOOL", "zone grise — on = le rappel de budget d'outils part au bout du dernier résultat d'outil au lieu d'un " +
"message à part, sur le moteur local et seulement si la sonde de gabarit dit que le rendu bouge quand un message " +
"s'ajoute (Qwen3.5) : la boucle d'outils du tour n'est plus recalculée. Un modèle peut moins bien suivre une consigne " +
"lue dans une sortie d'outil : à comparer avant de l'adopter. Vide/off (défaut) = message à part"},
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"},
{"TEMP", "température d'échantillonnage ; vide = défaut du moteur"},
@@ -105,6 +167,10 @@ var configTemplate = []struct{ key, help string }{
{"PRESENCE_PENALTY", "pénalité de présence ; vide = défaut du moteur"},
{"REPEAT_PENALTY", "pénalité de répétition (1 = neutre) ; vide = défaut du moteur"},
{"COMPACT", "compactage automatique du contexte (off pour couper)"},
{"COMPACT_CONTINUATION", "on = le résumé d'une compaction prolonge la requête du tour que le moteur local a en cache " +
"(mêmes messages, outils et réglages du gabarit, plus une demande de résumé) au lieu d'une transcription calculée à froid ; " +
"repli sur la transcription au moindre écart (autre requête passée par le slot, marge, refus, appel d'outil, résumé vide). " +
"Ne redemande pas un résumé voué au refus. Vide/off (défaut) = compaction d'avant ; sans effet sur un preset externe"},
{"MEM_MODE", "mémoire de l'IA : off / ondemand / always"},
{"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"},
}
+2 -2
View File
@@ -48,9 +48,9 @@ func launchdPlistPath(svc string) string {
// en écriture par l'utilisateur du service après le chown de l'installation).
func launchdLogPath() string { return filepath.Join(LokiHome(), serviceName()+".log") }
// serviceAction mappe start/stop/restart/enable/disable sur launchctl. `load -w`
// serviceActionOS mappe start/stop/restart/enable/disable sur launchctl. `load -w`
// (re)active le service ET le rend persistant au boot ; `unload -w` le désactive.
func serviceAction(action string) error {
func serviceActionOS(action string) error {
svc := serviceName()
plist := launchdPlistPath(svc)
// Pas de LaunchDaemon installé — cas normal d'un Mac de bureau, où Loki tourne
+2 -2
View File
@@ -11,11 +11,11 @@ import (
"time"
)
// serviceAction wraps `systemctl <action> <svc>` with passwordless sudo where
// serviceActionOS wraps `systemctl <action> <svc>` with passwordless sudo where
// it makes sense, and prints a follow-up status check after start/restart.
// Sans systemd (conteneur Docker, LOKI_CONTAINER=1), on bascule sur la
// supervision par fichier PID (sys_service_container.go).
func serviceAction(action string) error {
func serviceActionOS(action string) error {
if !systemdAvailable() {
return userSvcAction(action)
}
+1 -1
View File
@@ -32,7 +32,7 @@ func logFilePath() string { return filepath.Join(LokiHome(), serviceName()+".log
// systemdAvailable : jamais de systemd sous Windows (consulté par relay_link.go).
func systemdAvailable() bool { return false }
func serviceAction(action string) error {
func serviceActionOS(action string) error {
switch action {
case "start":
return svcStart()
+55
View File
@@ -0,0 +1,55 @@
//go:build unix
package loki
import (
"os/exec"
"syscall"
"time"
)
// Moteur d'essai de l'optimiseur (backend_tune_run.go), côté Unix.
//
// L'essai est un « loki serve » qui devient llama-server par exec : même PID,
// même groupe. Setpgid en fait le chef d'un groupe à lui — un signal au groupe
// (-pid) l'atteint, quoi qu'il ait lancé, et rien d'autre : ni le processus web
// qui l'a démarré, ni le vrai moteur.
func tuneTrialAttr(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
// tuneKillTree arrête le groupe de l'essai : SIGTERM, puis SIGKILL s'il traîne.
// done se ferme quand l'appelant a récolté le processus (cmd.Wait) ; nil pour un
// orphelin d'un ancien Loki, dont on ne peut que sonder l'existence — un enfant
// non récolté resterait « vivant » (zombie) aux yeux du signal 0.
func tuneKillTree(pid int, done <-chan struct{}) {
if pid <= 0 {
return
}
gone := func(wait time.Duration) bool {
deadline := time.Now().Add(wait)
for time.Now().Before(deadline) {
if done != nil {
select {
case <-done:
return true
case <-time.After(100 * time.Millisecond):
}
continue
}
if !processAlive(pid) {
return true
}
time.Sleep(100 * time.Millisecond)
}
return false
}
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
_ = syscall.Kill(pid, syscall.SIGTERM)
}
if gone(10 * time.Second) {
return
}
_ = syscall.Kill(-pid, syscall.SIGKILL)
gone(5 * time.Second)
}
+78
View File
@@ -0,0 +1,78 @@
//go:build windows
package loki
import (
"os/exec"
"strconv"
"syscall"
"time"
"unsafe"
)
// Moteur d'essai de l'optimiseur (backend_tune_run.go), côté Windows.
//
// L'essai est un « loki serve » qui lance llama-server en ENFANT (execServer) :
// deux processus. taskkill /T emporte l'arbre entier à partir du PID vérifié,
// comme svcStop le fait pour le vrai moteur. Pas de console : le processus web
// tourne souvent en mode application.
func tuneTrialAttr(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: createNewProcessGroup | createNoWindow,
}
}
// tuneKillTree arrête l'arbre de l'essai. done : voir la version Unix.
func tuneKillTree(pid int, done <-chan struct{}) {
if pid <= 0 {
return
}
_ = hideCmd(exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")).Run()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if done != nil {
select {
case <-done:
return
case <-time.After(100 * time.Millisecond):
}
continue
}
if !processAlive(pid) {
return
}
time.Sleep(100 * time.Millisecond)
}
}
// procIdentity : heure de création et image d'un processus vivant. Le PID seul
// ne suffit pas à désigner l'essai orphelin d'un Loki redémarré : Windows
// recycle vite ses PID, et tuer « le PID noté » pourrait viser n'importe quoi.
func procIdentity(pid int) (start, exe string, ok bool) {
if pid <= 0 || !processAlive(pid) {
return "", "", false
}
const queryLimitedInfo = 0x1000
k := syscall.NewLazyDLL("kernel32.dll")
h, _, _ := k.NewProc("OpenProcess").Call(queryLimitedInfo, 0, uintptr(pid))
if h == 0 {
return "", "", false
}
defer k.NewProc("CloseHandle").Call(h)
var creation, exit, kernel, user syscall.Filetime
r, _, _ := k.NewProc("GetProcessTimes").Call(h,
uintptr(unsafe.Pointer(&creation)), uintptr(unsafe.Pointer(&exit)),
uintptr(unsafe.Pointer(&kernel)), uintptr(unsafe.Pointer(&user)))
if r == 0 {
return "", "", false
}
start = strconv.FormatInt(creation.Nanoseconds(), 10)
buf := make([]uint16, 1024)
size := uint32(len(buf))
if r, _, _ := k.NewProc("QueryFullProcessImageNameW").Call(h, 0,
uintptr(unsafe.Pointer(&buf[0])), uintptr(unsafe.Pointer(&size))); r != 0 {
exe = syscall.UTF16ToString(buf[:size])
}
return start, exe, true
}
+13
View File
@@ -243,6 +243,13 @@ func cmdUpdate(args []string) error {
fmt.Println(containerUpdateNote)
return nil
}
if !checkOnly {
// Même règle que l'interface : pas de remplacement de binaire pendant
// une optimisation.
if err := tuneGuard(); err != nil {
return err
}
}
fmt.Println("recherche de la dernière version…")
info, err := checkForUpdate()
if err != nil {
@@ -458,6 +465,12 @@ func handleUpdateApply(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 409, map[string]any{"ok": false, "error": containerUpdateNote})
return
}
// Optimisation en cours : la mise à jour relance Loki, ce qui couperait
// l'optimisation du processus web — ou ferait passer pour mort le
// propriétaire d'une optimisation en ligne de commande.
if tuneDenyHTTP(w) {
return
}
newVer, err := applyUpdate()
if err != nil {
// 500 : un client script peut tester le statut HTTP ; l'UI, elle, lit le JSON.
+9
View File
@@ -30,6 +30,9 @@ import (
// lastReport est le compte-rendu du passage précédent (vide au premier) : il est
// réinjecté pour donner à l'IA une continuité d'une exécution à l'autre.
func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prompt, lastReport string, caps Caps, temperature float64) (string, error) {
if err := tuneGuard(); err != nil {
return "", err
}
if !healthCheck() {
return "", errModelLoading
}
@@ -170,6 +173,12 @@ func runTask(t Task) {
return
}
// Préchauffage de la discussion ouverte (PREWARM, off par défaut) une fois
// la tâche finie : différé AVANT la levée du projet forcé, donc exécuté
// APRÈS elle — le contexte préparé est celui de la discussion, pas de la
// tâche. Le slot a déjà été effacé à la fin de RunAutonomous.
defer conv.prewarmKick(prewarmAfterTask)
// Projet visé : on force le projet vu par memoryDir() et par les trackers, le
// temps du passage. Sans ça, une veille rattachée au projet « NAS » écrirait
// dans la mémoire du projet ouvert à l'écran au moment du tic. Sûr : une seule
+2 -1
View File
@@ -49,7 +49,8 @@ func primeNextRuns() {
// toute façon impossible (gate de génération), et étaler leur départ évite qu'une
// rafale de tâches monopolise le modèle d'un coup.
func tickTasks(now time.Time) {
if tasksPaused() {
if tasksPaused() || tuneGuard() != nil {
// Optimisation en cours : la tâche due attend sa fin, elle n'échoue pas.
return
}
nowMs := now.UnixMilli()
+4
View File
@@ -89,6 +89,10 @@ func deleteToolResultsFor(sid string) {
return
}
prefix := sid + "."
// D'abord l'écrivain (chat_persist.go) : ce qui l'attend est jeté, et ce
// qu'il a déjà pris dans son lot est annulé — sans ça, il l'écrirait après
// l'effacement ci-dessous et le résultat renaîtrait. Annulé AVANT notre
// transaction : l'écrivain vérifie dans la sienne, et bbolt les sérialise.
persistQ.dropToolRes(prefix)
_ = update(bkToolRes, func(b *bolt.Bucket) error {
c := b.Cursor()
+36 -11
View File
@@ -498,23 +498,48 @@ const trackerIndexPrefix = "Trackers (tracker tool)"
// la dernière valeur — SANS appeler l'outil : elle ne l'appelle que pour fouiller
// l'historique ou pour modifier. Quelques lignes, jamais le contenu.
func trackerIndexMessage() (Message, bool) {
if memMode() != MemAlways {
list, ok := trackerIndexList()
if !ok {
return Message{}, false
}
return renderTrackerIndex(list, ""), true
}
// trackerIndexList : les trackers à annoncer, ok=false hors mode mémoire
// proactif ou s'il n'y en a aucun. Séparé du rendu pour le bloc figé
// (chat_projsnap.go), qui en tire aussi son état ligne à ligne.
func trackerIndexList() ([]trackerMeta, bool) {
if memMode() != MemAlways {
return nil, false
}
list := trackerList()
if len(list) == 0 {
return Message{}, false
return list, len(list) > 0
}
// trackerIndexLine : la ligne d'un tracker, « nom — dernière valeur (date) ».
func trackerIndexLine(m trackerMeta) string {
last := ""
if m.last != nil {
last = " — " + m.last.Text + " (" + fmtEvent(*m.last) + ")"
}
return m.Name + last
}
// renderTrackerIndex : le message d'index des trackers. asOf vide = la forme
// vivante (texte d'avant PROJ_SNAPSHOT, à l'octet près). Sinon la copie figée :
// datée, et sans « réponds directement d'ici », puisque des <context_update>
// plus récents peuvent en remplacer des lignes.
func renderTrackerIndex(list []trackerMeta, asOf string) Message {
var b strings.Builder
b.WriteString(trackerIndexPrefix + " — this is the COMPLETE list of your trackers, each with its LATEST point. To say which trackers exist or give a latest value, answer straight from this — do NOT call tracker with no arguments to list them, it would only repeat this. Call tracker(name[, when]) only to look further back in history, or with action \"add\"/\"edit\"/\"delete\" to change data.\n")
for _, m := range list {
last := ""
if m.last != nil {
last = " — " + m.last.Text + " (" + fmtEvent(*m.last) + ")"
}
fmt.Fprintf(&b, "- %s%s\n", m.Name, last)
if asOf == "" {
b.WriteString(trackerIndexPrefix + " — this is the COMPLETE list of your trackers, each with its LATEST point. To say which trackers exist or give a latest value, answer straight from this — do NOT call tracker with no arguments to list them, it would only repeat this. Call tracker(name[, when]) only to look further back in history, or with action \"add\"/\"edit\"/\"delete\" to change data.\n")
} else {
b.WriteString(trackerIndexPrefix + " as of " + asOf + trackerFrozenHead)
}
return Message{Role: "system", Content: strings.TrimRight(b.String(), "\n")}, true
for _, m := range list {
fmt.Fprintf(&b, "- %s\n", trackerIndexLine(m))
}
return Message{Role: "system", Content: strings.TrimRight(b.String(), "\n")}
}
// --- outil modèle -------------------------------------------------------------
+383 -16
View File
@@ -3428,6 +3428,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<div class="pe-gh">Cartes graphiques</div>
<div class="pe-list" id="m-gpu-list"></div>
<div class="pe-note" id="m-gpu-note"></div>
<!-- Guide de placement (placementGuideHtml) : ordre des cartes, liaison
PCIe, marges FIT_TARGET. Un conseil, jamais appliqué d'office. -->
<div class="pe-note" id="m-gpu-guide" hidden></div>
</div>
</div>
@@ -3448,7 +3451,14 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div>
<div class="pe-row">
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent)</span></span>
<span class="pe-row-c"><input id="s-moe" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="eaSetValued('--n-cpu-moe', this.value)"></span>
<span class="pe-row-c"><input id="s-moe" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="eaSetValued('--n-cpu-moe', this.value); refreshMoeSoon()"></span>
</div>
<!-- Avis MoE (refreshMoe) : experts placés à la main, UBATCH conseillé,
et l'assistant « Dupliquer en placement auto » — une COPIE du
preset, l'original reste tel quel. Masqué tant qu'il n'y a rien à dire. -->
<div class="pe-row stack" id="m-moe-row" style="display:none">
<div class="pe-note" id="m-moe-note"></div>
<button class="pe-link" id="m-moe-dup" onclick="dupAutoPlacement()" hidden>Dupliquer en placement auto…</button>
</div>
<div class="pe-row">
<span class="pe-row-l">Threads CPU<span class="pe-sub">vide ou 0 = cœurs physiques</span></span>
@@ -3464,7 +3474,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div>
<div class="pe-row">
<span class="pe-row-l">UBATCH</span>
<span class="pe-row-c"><input id="s-ubatch" class="pe-val" type="number" min="0" step="128" placeholder="512" oninput="cfgWriteKey('UBATCH', this.value)"></span>
<span class="pe-row-c"><input id="s-ubatch" class="pe-val" type="number" min="0" step="128" placeholder="512" oninput="cfgWriteKey('UBATCH', this.value); refreshMoeSoon()"></span>
</div>
<div class="pe-row">
<!-- Cache de prompts en RAM hôte (--cache-ram, Mio) : copie exacte de
@@ -3486,7 +3496,8 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<option value="bf16">bf16 (numérique différente de f16)</option>
</select></span></span>
</div>
<!-- Décodage spéculatif : « auto » et « MTP » écrivent la clé SPEC,
<!-- Décodage spéculatif : « auto », « MTP », « n-grammes du contexte » et
« MTP + n-grammes » écrivent la clé SPEC,
que Loki traduit au lancement (tête MTP détectée dans le fichier,
garde-fous VRAM) ; les autres types vont tels quels dans
--spec-type. « non » écrit SPEC=off. Le nombre de jetons anticipés
@@ -3497,6 +3508,8 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<span class="pe-row-c"><span class="pe-selc"><select id="s-spec" onchange="onSpecType()">
<option value="">non</option>
<option value="auto">auto (MTP si le modèle en a une et que la VRAM suit)</option>
<option value="ngram">n-grammes du contexte (expérimental · mode code, à mesurer)</option>
<option value="mtp+ngram">MTP + n-grammes (expérimental · à mesurer)</option>
<optgroup label="Modèle brouillon">
<option value="draft-mtp">MTP (imposé)</option>
<option value="draft-eagle3">EAGLE-3</option>
@@ -3549,7 +3562,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
(normalizeLoadFlags). -->
<div class="pe-row">
<span class="pe-row-l">Chargement<span class="pe-sub" id="s-loadmode-sub"></span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-loadmode" onchange="eaSetLoadMode(this.value)">
<span class="pe-row-c"><span class="pe-selc"><select id="s-loadmode" onchange="eaSetLoadMode(this.value); refreshMoeSoon()">
<option value="">auto</option>
<option value="mmap">mmap</option>
<option value="none">none</option>
@@ -3558,6 +3571,17 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<option value="dio">dio</option>
</select></span></span>
</div>
<!-- Mode résident (none, mlock, dio, mmap+mlock) avec trop de poids en
RAM : l'avertissement du lancement (loadModeRisk), ou son refus. -->
<div class="pe-row stack" id="s-loadmode-warn-row" style="display:none">
<div class="pe-note" id="s-loadmode-warn" style="color:var(--warn)"></div>
</div>
<!-- Optimiseur sans perte (backend_tune*.go) : essais sur un moteur
privé, mesurés au bench complet. Rien n'est écrit sans un clic. -->
<div class="pe-row" id="m-tune-row">
<span class="pe-row-l">Optimiseur<span class="pe-sub" id="m-tune-sub">lots, threads, marges --fit… mesurés sur un moteur privé · rien n'est écrit sans ton accord</span></span>
<span class="pe-row-c"><button class="pe-link" onclick="openTuneModal()">Optimiser…</button></span>
</div>
</div>
</div>
@@ -3625,6 +3649,24 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div>
</div>
</div>
<!-- Optimiseur sans perte (29-tune.js) : au-dessus de l'éditeur de preset, d'où
il s'ouvre. Les options cochées ici ne s'enregistrent nulle part. -->
<div id="tune-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:40;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeTuneModal()">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:640px;display:flex;flex-direction:column;max-height:90vh">
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
<strong>Optimiser le preset</strong>
<button onclick="closeTuneModal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
</div>
<div id="tune-body" style="padding:16px 18px;display:flex;flex-direction:column;gap:10px;overflow:auto"></div>
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px;flex-wrap:wrap">
<button id="tune-cancel" onclick="cancelTuneUI()" style="display:none">annuler l'optimisation</button>
<button id="tune-copy" onclick="applyTuneUI('copy')" style="display:none">enregistrer dans une copie</button>
<button id="tune-apply" onclick="applyTuneUI('preset')" style="display:none;border-color:var(--accent);color:var(--accent)">appliquer à ce preset…</button>
<button id="tune-start" onclick="startTuneUI()" style="border-color:var(--accent);color:var(--accent)">lancer</button>
<button onclick="closeTuneModal()">fermer</button>
</div>
</div>
</div>
<script src="marked.min.js"></script>
<script>
// marked: GFM on, no auto-breaks (single newlines stay inline).
@@ -4378,8 +4420,12 @@ let pendingPreset = 0;
// redessin et la puce semblait clignoter en boucle. On ne les rejoue que quand la
// sélection CHANGE vraiment.
let paintedSel = '';
// Dernière liste lue : l'éditeur y voit si le preset ouvert est celui en
// service (ligne « Optimiser », qui ne mesure que lui).
let presetsLast = [];
async function loadPresets(){
const p=await jget('/api/presets');
presetsLast = Array.isArray(p) ? p : [];
// Bascule terminée : le preset visé est devenu l'actif, on éteint l'attente.
if(pendingPreset && p[pendingPreset-1] && p[pendingPreset-1].active) pendingPreset = 0;
const act = p.find(x=>x.active);
@@ -5700,9 +5746,11 @@ function renderGpu(){
// Mémoire à 0 = le moteur n'a pas pu la lire (carte déjà saturée par le
// modèle en cours) : on n'affiche pas « 0,0 Go », qui serait un mensonge.
const gb = d.total_mib > 0 ? ' · ' + (d.total_mib/1024).toFixed(1).replace('.', ',') + ' Go' : '';
const link = gpuLinkText(d);
return '<div class="pe-row">'
+ '<span class="pe-row-l">' + gpuEsc(gpuLabel(d))
+ '<span class="pe-sub">' + gpuEsc(d.id) + gb + '</span></span>'
+ '<span class="pe-sub" title="' + gpuEsc(gpuLinkTip(d)) + '">' + gpuEsc(d.id) + gb
+ (link ? ' · ' + gpuEsc(link) : '') + '</span></span>'
+ '<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" value="' + gpuEsc(d.id) + '"'
+ (sel.includes(d.id) ? ' checked' : '') + ' onchange="onGpuPick()"><span class="pe-sl"></span></label></span>'
+ '</div>';
@@ -5712,10 +5760,83 @@ function renderGpu(){
document.getElementById('m-gpu-note').textContent = sel.length === gpuDevices.length
? 'Toutes les cartes sont utilisées.'
: 'Ce modèle n\'utilisera que : ' + sel.map(id => gpuLabel(gpuById(id))).join(', ') + '.';
const guide = document.getElementById('m-gpu-guide');
if(guide){ guide.innerHTML = sel.length > 1 ? placementGuideHtml(sel) : ''; guide.hidden = sel.length < 2; }
group.hidden = false;
if(sel.length > 1) paintSplit();
}
// Liaison PCIe d'une carte (nvidia-smi, joint par nom côté serveur, CUDA
// seulement) : le MAXIMUM, seul parlant — l'actuel descend au repos.
function gpuLinkText(d){
const l = d && d.link;
if(!l || !(l.gen_max || l.width_max)) return '';
return 'PCIe' + (l.gen_max ? ' ' + l.gen_max : '') + (l.width_max ? ' ×' + l.width_max : '');
}
function gpuLinkTip(d){
const l = d && d.link;
if(!l) return '';
const t = [];
if(l.gen || l.width) t.push('liaison actuelle : PCIe ' + (l.gen||'?') + ' ×' + (l.width||'?') + ' (réduite au repos, normale sous charge)');
if(l.mem_clock_mhz) t.push('horloge mémoire max : ' + l.mem_clock_mhz + ' MHz');
return t.join('\n');
}
// Guide de placement entre cartes inégales. CONSEIL seulement : rien n'est
// écrit dans le preset sans un clic, et Loki ne change jamais l'ordre ni les
// marges d'office. Deux règles du moteur, qui peuvent s'opposer :
// - --fit (placement auto) remplit les cartes en partant de la DERNIÈRE, qui
// porte aussi la couche de sortie : un modèle dense décode plus vite si la
// carte rapide est en dernier, ou si la lente garde une marge plus large ;
// - les experts MoE restés en RAM sont recopiés, au prefill, vers la PREMIÈRE
// carte : à elle la liaison PCIe la plus large.
// « Première » et « dernière » s'entendent dans l'ordre du moteur, --device
// compris — pas « CUDA0 ».
function placementGuideHtml(sel){
const name = id => gpuEsc(gpuLabel(gpuById(id)));
const order = sel.map((id, i) => (i+1) + '. ' + name(id)
+ (gpuLinkText(gpuById(id)) ? ' (' + gpuEsc(gpuLinkText(gpuById(id))) + ')' : '')).join(' → ');
const parts = ['<b>Ordre vu par le moteur</b> : ' + order
+ (sel.length === 2 ? ' · <button class="pe-link" onclick="reverseGpuOrder()">inverser l\'ordre</button>' : '')];
parts.push('• Modèle dense en placement auto : --fit remplit d\'abord la <b>dernière</b> carte, qui porte aussi '
+ 'la couche de sortie — la plus rapide en dernier, ou une marge FIT_TARGET plus large sur la lente '
+ '(ex. FIT_TARGET=1024,3072 quand la lente est seconde ; 1024 au minimum).');
parts.push('• Experts MoE sur CPU : le prefill les recopie vers la <b>première</b> carte — à elle la liaison PCIe la plus large.');
const ft = cfgReadKey('FIT_TARGET');
if(ft){
const v = ft.split(/[,/]/).map(s => s.trim());
parts.push('FIT_TARGET : ' + (v.length === 1 ? gpuEsc(v[0]) + ' Mio sur chaque carte'
: sel.map((id, i) => name(id) + ' ' + gpuEsc(v[i] || '1024') + ' Mio').join(' · ')));
}
if(eaGetValued('--tensor-split'))
parts.push('--tensor-split fixe la répartition : --fit et FIT_TARGET ne jouent pas.');
const cvd = cfgReadKey('CUDA_VISIBLE_DEVICES');
if(cvd) parts.push('CUDA_VISIBLE_DEVICES=' + gpuEsc(cvd) + ' dans ce preset : l\'ordre réel peut différer de cette '
+ 'liste, établie avec la sélection de la machine — et les marges FIT_TARGET suivent l\'ordre réel.');
parts.push('Les deux règles peuvent s\'opposer : à départager par la mesure, preset par preset. Loki ne bascule pas '
+ 'le moteur tout seul pour comparer (deux rechargements complets, et un ordre inversé peut manquer de VRAM sur la '
+ 'petite carte) : duplique le preset, inverse l\'ordre dans la copie, puis « bench complet » sur chacun. '
+ 'Vérifie « offloaded N/N layers » au journal.');
return parts.join('<br>');
}
// Inverse l'ordre des deux cartes (--device), et la répartition avec elles.
// Geste explicite de l'utilisateur, écrit dans le preset édité seulement.
function reverseGpuOrder(){
const sel = selectedGpuIds();
if(sel.length !== 2) return;
const rev = sel.slice().reverse();
eaSetValued('--device', gpuNaturalOrder(rev) ? '' : rev.join(','));
const ts = eaGetValued('--tensor-split');
if(ts) eaSetValued('--tensor-split', ts.split(',').reverse().join(','));
renderGpu();
toast('ordre : ' + rev.map(id => gpuLabel(gpuById(id))).join(' → '));
}
// Toutes les cartes, dans l'ordre du moteur : aucune contrainte à écrire.
function gpuNaturalOrder(ids){
return ids.length === gpuDevices.length && ids.every((id, i) => id === gpuDevices[i].id);
}
function gpuById(id){ return gpuDevices.find(d => d.id === id) || {id}; }
// Nom lisible : « NVIDIA GeForce RTX 5060 Ti » → « RTX 5060 Ti ». CUDA0/Vulkan1
// ne disent rien à personne ; le numéro reste en sous-titre pour ceux qui
@@ -5833,9 +5954,13 @@ function resetSplit(){
function onGpuPick(){
const ids = [...document.querySelectorAll('#m-gpu-list input[type=checkbox]:checked')].map(c => c.value);
if(!ids.length){ toast('gardez au moins une carte'); renderGpu(); return; }
// Toutes cochées = pas de contrainte : on retire le flag plutôt que de figer
// des noms de device qui changeraient avec le moteur.
eaSetValued('--device', ids.length === gpuDevices.length ? '' : ids.join(','));
// L'ordre déjà choisi (« inverser l'ordre ») est gardé pour les cartes restées
// cochées ; les nouvelles viennent à la suite, dans l'ordre du moteur.
const prev = eaGetValued('--device').split(',').filter(id => ids.includes(id));
const order = prev.concat(ids.filter(id => !prev.includes(id)));
// Toutes cochées dans l'ordre du moteur = pas de contrainte : on retire le
// flag plutôt que de figer des noms de device qui changeraient avec le moteur.
eaSetValued('--device', gpuNaturalOrder(order) ? '' : order.join(','));
// Une répartition écrite pour N cartes n'a plus de sens pour N-1.
if(ids.length < 2) eaSetValued('--tensor-split', '');
renderGpu();
@@ -5907,6 +6032,14 @@ function populateSettings(){
set('s-ubatch', cfgReadKey('UBATCH'));
set('s-cram', cfgReadKey('CACHE_RAM'));
refreshCacheRAM();
refreshMoe();
// L'optimiseur mesure le preset EN SERVICE (local) : sa ligne n'apparaît que
// dans l'éditeur de celui-ci, pas d'un autre preset ni d'un preset neuf.
const tuneRow = document.getElementById('m-tune-row');
if(tuneRow){
const act = (typeof presetsLast !== 'undefined' ? presetsLast : []).find(x => x.active);
tuneRow.style.display = (act && !act.external && editingKey && act.id === editingKey) ? '' : 'none';
}
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
set('s-kv', cfgReadKey('KV_TYPE'));
syncKVSub();
@@ -5998,8 +6131,60 @@ async function refreshCacheRAM(){
const fixed = /^fixé/.test(r.why || '');
el.placeholder = fixed ? (r.mib < 0 ? 'sans limite' : r.mib === 0 ? 'coupé' : String(r.mib))
: 'auto · ' + (r.mib > 0 ? r.mib : r.default);
// SIDE_SLOT : la VRAM du second slot, ou la raison du refus au lancement.
const side = r.side ? (r.side.on ? ' · SIDE_SLOT : +' + r.side.vramMiB + ' Mio de VRAM'
: ' · SIDE_SLOT refusé : ' + r.side.why) : '';
if(sub) sub.textContent = 'RAM hôte, copie exacte du contexte · jamais la VRAM' +
(r.why ? ' · ' + r.why : '') + (r.mib > 0 || fixed ? '' : ' · défaut du moteur');
(r.why ? ' · ' + r.why : '') + (r.mib > 0 || fixed ? '' : ' · défaut du moteur') + side;
}
// Avis MoE et mode de chargement, calculés côté serveur comme au lancement
// (backend_serve_moe.go) : experts placés à la main, UBATCH conseillé, moteur
// sans --fit, chargement résident trop gros pour la RAM. Des avis seulement :
// rien n'est écrit dans le preset. Une demande en vol ; la dernière gagne.
let moeSeq = 0, moeTimer = null, moeChanges = null;
function refreshMoeSoon(){ clearTimeout(moeTimer); moeTimer = setTimeout(refreshMoe, 400); }
async function refreshMoe(){
const row = document.getElementById('m-moe-row'), note = document.getElementById('m-moe-note');
const dup = document.getElementById('m-moe-dup');
const lrow = document.getElementById('s-loadmode-warn-row'), lw = document.getElementById('s-loadmode-warn');
if(!row) return;
const seq = ++moeSeq;
let r = {};
try{ r = await jpost('/api/preset/moe', {content: document.getElementById('m-content').value}); }
catch(_){ r = {}; }
if(seq !== moeSeq) return;
const ap = r.ok ? r.autoplace : null;
const parts = (r.ok && r.notes) ? r.notes.slice() : [];
if(ap && !ap.ok) parts.push('placement auto impossible : ' + ap.why);
note.textContent = parts.join(' · ');
moeChanges = (ap && ap.ok) ? (ap.changes || []) : null;
dup.hidden = !moeChanges;
row.style.display = (parts.length || moeChanges) ? '' : 'none';
if(lrow && lw){
lw.textContent = (r.ok && r.load) || '';
lrow.style.display = lw.textContent ? '' : 'none';
}
}
// « Dupliquer en placement auto » : une COPIE du preset tel qu'affiché, sans
// les experts placés à la main (--fit les répartit carte par carte), UBATCH
// 2048 / BATCH 4096, NGL auto, chargement mmap, CTX et cache KV intacts. Le
// preset d'origine ne bouge pas : c'est le repli si --fit échoue.
async function dupAutoPlacement(){
if(!moeChanges) return;
const msg = 'Créer une COPIE de ce preset en placement automatique (--fit place les experts carte par carte) :\n\n'
+ moeChanges.map(c => '• ' + c).join('\n')
+ '\n\nLe preset d\'origine reste tel quel : c\'est le repli. Après la bascule sur la copie, le journal doit dire '
+ '« successfully fit params » — « failed to fit » met tous les experts sur GPU : revenir à l\'original. '
+ 'Puis comparer les deux au « bench complet ».';
if(!await askConfirm(msg, {title:'Placement auto', okText:'Créer la copie'})) return;
let r = {};
try{
r = await jpost('/api/preset/autoplace', {id: editingKey || '', name: document.getElementById('m-name').value.trim(),
content: document.getElementById('m-content').value});
}catch(e){ r = {ok:false, error:e.message}; }
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('copie créée : ' + r.name);
KINDS.preset.reload();
}
function syncKVSub(){
const el = document.getElementById('s-kv-sub');
@@ -6073,10 +6258,14 @@ function syncLoadModeSub(mode){
// « auto » et « MTP » passent par la clé SPEC : Loki cherche la tête MTP dans
// le fichier et pose les garde-fous au lancement. Les autres types restent des
// --spec-type d'EXTRA_ARGS, qui ont toujours le dernier mot côté serveur.
// « ngram » et « mtp+ngram » aussi : Loki écrit lui-même les bornes de
// ngram-mod et refuse là où les longs lots de vérification coûteraient cher.
function specFromKey(v){
v = String(v||'').trim().toLowerCase();
return v === 'auto' ? 'auto' : v === 'mtp' ? 'draft-mtp' : '';
if(v === 'ngram+mtp') v = 'mtp+ngram';
return v === 'auto' ? 'auto' : v === 'mtp' ? 'draft-mtp' : (v === 'ngram' || v === 'mtp+ngram') ? v : '';
}
const SPEC_KEY_VALUES = {'auto': 'auto', 'draft-mtp': 'mtp', 'ngram': 'ngram', 'mtp+ngram': 'mtp+ngram'};
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
// par des virgules ; une valeur composée (ou un type sorti après cette version
// de loki) ne correspondrait à aucune option et serait silencieusement effacée
@@ -6101,20 +6290,21 @@ function syncSpecRow(){
const row = document.getElementById('s-spec-n-row');
if(!sel || !row) return;
const on = !!sel.value;
row.style.display = on ? '' : 'none';
// SPEC=ngram n'a pas de brouillon : un --spec-draft-n-max le ferait refuser.
row.style.display = on && sel.value !== 'ngram' ? '' : 'none';
// Le brouillon ne sert qu'à SPEC (auto / MTP) ; un brouillon déjà posé reste
// visible pour qu'on puisse le retirer.
const dr = document.getElementById('s-draft-row');
if(dr) dr.style.display = (sel.value === 'auto' || sel.value === 'draft-mtp' || cfgReadKey('MODEL_DRAFT')) ? '' : 'none';
if(dr) dr.style.display = (sel.value === 'auto' || sel.value === 'draft-mtp' || sel.value === 'mtp+ngram' || cfgReadKey('MODEL_DRAFT')) ? '' : 'none';
return on;
}
function onSpecType(){
const v = document.getElementById('s-spec').value;
const viaKey = v === 'auto' || v === 'draft-mtp';
const viaKey = v in SPEC_KEY_VALUES;
// « non » écrit SPEC=off : explicite, et identique au défaut du serveur.
cfgWriteKey('SPEC', v === 'auto' ? 'auto' : v === 'draft-mtp' ? 'mtp' : v ? '' : 'off');
cfgWriteKey('SPEC', viaKey ? SPEC_KEY_VALUES[v] : v ? '' : 'off');
eaSetValued('--spec-type', viaKey ? '' : v);
if(!v){
if(!v || v === 'ngram'){
eaSetValued('--spec-draft-n-max', '');
document.getElementById('s-spec-n').value = '';
}
@@ -7520,7 +7710,7 @@ function renderStats(el, s){
}
// Ce qui s'est intercalé avant une perte de cache (lost_after, perf_log.go),
// dit en clair : les identifiants internes n'ont rien à faire dans l'interface.
const PERF_KIND={main:'tour', subagent:'sous-agent', verify:'vérification', task:'tâche', compact:'compaction', bench:'bench', foreign:'client /v1'};
const PERF_KIND={main:'tour', subagent:'sous-agent', verify:'vérification', task:'tâche', compact:'compaction', bench:'bench', foreign:'client /v1', prewarm:'préchauffage'};
// Milliers séparés par une espace fine insécable : « 41 230 », pas « 41230 ».
function nfmt(n){ return String(Math.round(n||0)).replace(/\B(?=(\d{3})+(?!\d))/g,'\u202f'); }
// --- Compteurs de bulle -----------------------------------------------------
@@ -11745,4 +11935,181 @@ document.addEventListener('DOMContentLoaded', () => {
i18nApply(document.body);
i18nWatch();
});
// Optimiseur sans perte (backend_tune*.go). Il tourne côté serveur, en tâche de
// fond : la modale lance, suit, montre les mesures — et n'écrit RIEN sans un
// clic (copie du preset, ou ce preset après confirmation du diff).
let tunePoll = null, tuneState = 'form';
// Étapes de base (tuneStages côté serveur) ; placement et opt-in ont leur case.
const TUNE_STAGES = [['lots','micro-lot et lot'], ['threads','threads'], ['delestage','délestage'], ['marges','marges --fit'], ['files','files CUDA']];
function closeTuneModal(){ clearTimeout(tunePoll); hideModal('tune-modal'); }
function tuneBtns(state){
tuneState = state;
const show = (id, on) => { const b = document.getElementById(id); if(b) b.style.display = on ? '' : 'none'; };
show('tune-start', state !== 'running');
const sb = document.getElementById('tune-start');
if(sb) sb.textContent = state === 'form' ? 'lancer' : 'nouvelle mesure…';
show('tune-cancel', state === 'running');
show('tune-copy', state === 'result');
show('tune-apply', state === 'result');
}
function tuneSec(s){
s = Math.max(0, Math.round(s||0));
return s >= 60 ? Math.floor(s/60) + ' min ' + String(s%60).padStart(2,'0') + ' s' : s + ' s';
}
async function openTuneModal(){
showModal('tune-modal');
const body = document.getElementById('tune-body');
body.innerHTML = '<div class="muted">…</div>';
let st = {};
try{ st = await jget('/api/tune/status'); }catch(_){}
// Une mesure finie (modale fermée pendant qu'elle tournait) : son résultat
// d'abord, avec ses boutons — « nouvelle mesure… » ramène au formulaire.
if(st.running || st.applying || st.apply || (st.result && st.result.preset_id === editingKey)){ tuneWatch(); return; }
let last = null;
if(editingKey){ try{ last = await jget('/api/tune/last?id=' + encodeURIComponent(editingKey)); }catch(_){} }
tuneForm(last && last.ok ? last : null, st.elsewhere);
}
function tuneForm(last, elsewhere){
const body = document.getElementById('tune-body');
let h = '<div class="pe-note">Des essais sur un moteur <b>privé</b> (127.0.0.1), chacun mesuré au bench complet : '
+ 'micro-lot et lot, threads (poids sur CPU), seuil de délestage, marges <code>--fit</code> et files CUDA (2 cartes ou plus). '
+ 'Jamais touchés : modèle, contexte, cache KV, raisonnement, échantillonnage, slots. Le moteur principal est arrêté pendant la mesure '
+ '(chat et tâches en pause), puis relancé. Rien n\'est écrit sans ton accord.</div>'
+ '<div class="pe-note muted">Mesure la version <b>enregistrée</b> du preset en service.</div>'
+ '<div style="display:flex;gap:10px;flex-wrap:wrap;font-size:12px"><span class="muted">Étapes :</span>'
+ TUNE_STAGES.map(s => '<label style="display:flex;gap:4px;align-items:center"><input type="checkbox" class="tune-stage" value="' + s[0] + '" checked> ' + s[1] + '</label>').join('')
+ '</div>'
+ '<label style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" id="tune-placement"> <span>Inclure le placement'
+ '<span class="muted" style="display:block;font-size:11px">--fit au lieu des experts MoE ou du --tensor-split placés à la main : réécrit EXTRA_ARGS, confirmé à part</span></span></label>'
+ '<label style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" id="tune-optin"> <span>Inclure les options opt-in'
+ '<span class="muted" style="display:block;font-size:11px">SPEC (sortie inchangée, jugée sur prose et code), SPEC_N_MAX, CUDA_GRAPH_OPT (expérimental), --backend-sampling</span></span></label>'
+ '<label style="display:flex;gap:8px;align-items:center">Budget <input type="number" id="tune-budget" class="pe-val" min="5" max="360" step="5" value="30" style="width:80px"> minutes'
+ '<span class="muted" style="font-size:11px">— au-delà, résultat partiel</span></label>';
if(elsewhere) h += '<div class="pe-note" style="color:var(--warn)">Une optimisation tourne déjà (' + escHtml(elsewhere) + ').</div>';
if(last && last.result){
const r = last.result;
const when = new Date(r.at*1000).toLocaleString();
h += '<div class="pe-note">Dernière optimisation : ' + escHtml(when) + (r.best ? ' — ' + escHtml(r.best) + ', −' + (r.gain*100).toFixed(1) + ' % par tour' : ' — rien de mieux trouvé') + '</div>';
if(last.engine_changed) h += '<div class="pe-note" style="color:var(--warn)">Le moteur a changé depuis (' + escHtml(last.engine_changed) + ') : les meilleurs réglages ont pu bouger — relance l\'optimisation.</div>';
if(last.preset_changed) h += '<div class="pe-note muted">Le preset a été modifié depuis cette mesure.</div>';
}
body.innerHTML = h;
tuneBtns('form');
}
async function startTuneUI(){
if(tuneState !== 'form'){
let last = null;
if(editingKey){ try{ last = await jget('/api/tune/last?id=' + encodeURIComponent(editingKey)); }catch(_){} }
tuneForm(last && last.ok ? last : null);
return;
}
const placement = !!(document.getElementById('tune-placement')||{}).checked;
const optin = !!(document.getElementById('tune-optin')||{}).checked;
const budget = parseInt((document.getElementById('tune-budget')||{}).value, 10) || 30;
// Toutes cochées = toutes (liste vide) ; aucune = seulement placement / opt-in.
const boxes = [...document.querySelectorAll('.tune-stage')];
const picked = boxes.filter(b => b.checked).map(b => b.value);
const stages = picked.length === boxes.length ? [] : (picked.length ? picked : ['aucune']);
const msg = 'Le moteur principal va être ARRÊTÉ pendant jusqu\'à ' + budget + ' minutes : chat, tâches et clients /v1 attendent. '
+ 'Chaque essai recharge le modèle. Lancer l\'optimisation ?';
if(!await askConfirm(msg, {title:'Optimiser', okText:'Lancer'})) return;
let r;
try{ r = await jpost('/api/tune', {placement, optin, budget_min: budget, stages}); }catch(e){ r = {ok:false, error:e.message}; }
if(!r.ok){ document.getElementById('tune-body').insertAdjacentHTML('beforeend', '<div style="color:var(--err)">' + escHtml(r.error||'?') + '</div>'); return; }
tuneWatch();
}
async function cancelTuneUI(){ try{ await jpost('/api/tune/cancel', {}); }catch(_){} }
function tuneTrialsHTML(trials){
if(!trials || !trials.length) return '';
let h = '<table style="width:100%;font-size:12px;border-collapse:collapse"><tr class="muted"><td>étape</td><td>essai</td>'
+ '<td style="text-align:right">tour</td><td style="text-align:right">VRAM libre</td><td>statut</td></tr>';
for(const t of trials){
const color = t.status === 'retenu' ? 'var(--ok)' : (t.status === 'échec' || t.status === 'écarté') ? 'var(--warn)' : 'inherit';
const runs = (t.runs||[]);
let tip = '';
if(runs.length){
const r0 = runs[0];
tip = 'prose ' + (r0.prose_pp||0).toFixed(0) + ' / ' + (r0.prose_tg||0).toFixed(1) + ' t/s';
if(r0.depth) tip += ' · à ' + r0.depth + ' jetons : froid ' + (r0.cold_pp||0).toFixed(0) + ', tours ' + (r0.cached_pp||0).toFixed(0) + ', decode ' + (r0.tg||0).toFixed(1) + ' t/s';
tip += ' · ' + runs.length + ' passage(s)';
}
if(t.offloaded) tip += ' · couches ' + t.offloaded;
h += '<tr title="' + escHtml(tip) + '"><td class="muted" style="padding:3px 6px 3px 0">' + escHtml(t.stage) + '</td>'
+ '<td>' + escHtml(t.label) + '</td>'
+ '<td style="text-align:right">' + (t.turn_sec ? t.turn_sec.toFixed(1) + ' s' : '—') + '</td>'
+ '<td style="text-align:right">' + (t.headroom_mib >= 0 ? t.headroom_mib + ' Mio' : '—') + '</td>'
+ '<td style="color:' + color + ';padding-left:6px">' + escHtml(t.status) + (t.why ? ' <span class="muted">· ' + escHtml(t.why) + '</span>' : '') + '</td></tr>';
}
return h + '</table>';
}
function tuneResultHTML(r){
let h = '<div class="pe-note muted">Score : durée d\'un tour type — ' + (r.weights.k).toFixed(0) + ' jetons lus (cache repris), '
+ (r.weights.g).toFixed(0) + ' écrits, ' + (r.weights.f_cold*100).toFixed(0) + ' % des tours relus à froid à ' + r.depth + ' jetons ('
+ escHtml(r.weights.from) + '). Un gain compte au-delà de 3 % et de l\'écart entre passages. Moteur ' + escHtml(r.engine||'?') + '.</div>';
h += tuneTrialsHTML([r.baseline].concat(r.trials||[]));
for(const s of (r.skipped||[])) h += '<div class="muted" style="font-size:11px">sauté — ' + escHtml(s) + '</div>';
for(const n of (r.notes||[])) h += '<div style="font-size:11px;color:var(--warn)">' + escHtml(n) + '</div>';
if(r.partial) h += '<div style="font-size:11px;color:var(--warn)">résultat partiel : ' + escHtml(r.partial) + '</div>';
if(r.best){
h += '<div style="margin-top:6px"><b style="color:var(--ok)">Meilleur : ' + escHtml(r.best) + '</b> — −' + (r.gain*100).toFixed(1) + ' % par tour</div>'
+ '<div class="pe-note" style="white-space:pre-wrap;font-family:var(--mono,monospace);font-size:11px">' + escHtml((r.changes||[]).join('\n')) + '</div>';
if(r.placement) h += '<div class="pe-note" style="color:var(--warn)">Ce résultat réécrit EXTRA_ARGS (placement) : à confirmer à part.</div>';
} else {
h += '<div style="margin-top:6px">La configuration actuelle reste la meilleure : rien à changer.</div>';
}
return h;
}
function tuneWatch(){
clearTimeout(tunePoll);
const body = document.getElementById('tune-body');
const tick = async () => {
let st;
try{ st = await jget('/api/tune/status'); }
catch(_){ tunePoll = setTimeout(tick, 3000); return; }
if(st.running){
tuneBtns('running');
const eta = st.eta_sec >= 0 ? ' · reste ~' + tuneSec(st.eta_sec) : '';
body.innerHTML = '<div class="muted" style="text-align:center">⏳ ' + escHtml(st.phase||'…') + '<br>' + tuneSec(st.elapsed_sec) + eta
+ '<br><span style="font-size:11px">moteur principal arrêté · chat et tâches en pause</span></div>' + tuneTrialsHTML(st.trials);
tunePoll = setTimeout(tick, 2000);
return;
}
let h = '';
if(st.error) h += '<div style="color:var(--err)">' + escHtml(st.error) + '</div>';
if(st.result) h += tuneResultHTML(st.result);
const a = st.apply;
if(a){
h += '<div class="pe-note">' + (a.log||[]).map(escHtml).join('<br>') + '</div>';
if(a.message) h += '<div style="color:var(--ok)">' + escHtml(a.message) + '</div>';
if(a.error) h += '<div style="color:var(--err)">' + escHtml(a.error) + '</div>';
}
body.innerHTML = h || '<div class="muted">rien à montrer</div>';
const canApply = !!(st.result && st.result.best) && !st.applying && !(a && a.message);
tuneBtns(canApply ? 'result' : 'done');
if(st.applying){ tunePoll = setTimeout(tick, 2000); }
};
tick();
}
async function applyTuneUI(target){
let st = {};
try{ st = await jget('/api/tune/status'); }catch(_){}
const r = st.result;
if(!r || !r.best) return;
const diff = (r.changes||[]).map(c => '• ' + c).join('\n');
if(target === 'copy'){
if(!await askConfirm('Créer une COPIE de « ' + r.preset_name + ' » avec :\n\n' + diff + '\n\nLe preset d\'origine ne bouge pas.',
{title:'Enregistrer dans une copie', okText:'Créer la copie'})) return;
} else {
if(!await askConfirm('Réécrire « ' + r.preset_name + ' » (sauvegardé avant) avec :\n\n' + diff
+ '\n\nLe moteur redémarre, puis une sonde vérifie un prompt à ' + r.depth + ' jetons ; en cas d\'échec, l\'ancienne version est rétablie.',
{title:'Appliquer à ce preset', okText:'Appliquer'})) return;
if(r.placement && !await askConfirm('Ce résultat RÉÉCRIT EXTRA_ARGS (placement des experts ou --tensor-split retirés pour --fit). Confirmer ?',
{title:'Placement', okText:'Confirmer'})) return;
}
let res;
try{ res = await jpost('/api/tune/apply', {target}); }catch(e){ res = {ok:false, error:e.message}; }
if(!res.ok){ toast('erreur : ' + (res.error||'')); return; }
if(target === 'copy'){ toast(res.message || 'copie créée'); KINDS.preset.reload(); return; }
tuneWatch();
}
</script></body></html>
+46 -4
View File
@@ -1202,6 +1202,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<div class="pe-gh">Cartes graphiques</div>
<div class="pe-list" id="m-gpu-list"></div>
<div class="pe-note" id="m-gpu-note"></div>
<!-- Guide de placement (placementGuideHtml) : ordre des cartes, liaison
PCIe, marges FIT_TARGET. Un conseil, jamais appliqué d'office. -->
<div class="pe-note" id="m-gpu-guide" hidden></div>
</div>
</div>
@@ -1222,7 +1225,14 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
<div class="pe-row">
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent)</span></span>
<span class="pe-row-c"><input id="s-moe" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="eaSetValued('--n-cpu-moe', this.value)"></span>
<span class="pe-row-c"><input id="s-moe" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="eaSetValued('--n-cpu-moe', this.value); refreshMoeSoon()"></span>
</div>
<!-- Avis MoE (refreshMoe) : experts placés à la main, UBATCH conseillé,
et l'assistant « Dupliquer en placement auto » — une COPIE du
preset, l'original reste tel quel. Masqué tant qu'il n'y a rien à dire. -->
<div class="pe-row stack" id="m-moe-row" style="display:none">
<div class="pe-note" id="m-moe-note"></div>
<button class="pe-link" id="m-moe-dup" onclick="dupAutoPlacement()" hidden>Dupliquer en placement auto…</button>
</div>
<div class="pe-row">
<span class="pe-row-l">Threads CPU<span class="pe-sub">vide ou 0 = cœurs physiques</span></span>
@@ -1238,7 +1248,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
<div class="pe-row">
<span class="pe-row-l">UBATCH</span>
<span class="pe-row-c"><input id="s-ubatch" class="pe-val" type="number" min="0" step="128" placeholder="512" oninput="cfgWriteKey('UBATCH', this.value)"></span>
<span class="pe-row-c"><input id="s-ubatch" class="pe-val" type="number" min="0" step="128" placeholder="512" oninput="cfgWriteKey('UBATCH', this.value); refreshMoeSoon()"></span>
</div>
<div class="pe-row">
<!-- Cache de prompts en RAM hôte (--cache-ram, Mio) : copie exacte de
@@ -1260,7 +1270,8 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<option value="bf16">bf16 (numérique différente de f16)</option>
</select></span></span>
</div>
<!-- Décodage spéculatif : « auto » et « MTP » écrivent la clé SPEC,
<!-- Décodage spéculatif : « auto », « MTP », « n-grammes du contexte » et
« MTP + n-grammes » écrivent la clé SPEC,
que Loki traduit au lancement (tête MTP détectée dans le fichier,
garde-fous VRAM) ; les autres types vont tels quels dans
--spec-type. « non » écrit SPEC=off. Le nombre de jetons anticipés
@@ -1271,6 +1282,8 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<span class="pe-row-c"><span class="pe-selc"><select id="s-spec" onchange="onSpecType()">
<option value="">non</option>
<option value="auto">auto (MTP si le modèle en a une et que la VRAM suit)</option>
<option value="ngram">n-grammes du contexte (expérimental · mode code, à mesurer)</option>
<option value="mtp+ngram">MTP + n-grammes (expérimental · à mesurer)</option>
<optgroup label="Modèle brouillon">
<option value="draft-mtp">MTP (imposé)</option>
<option value="draft-eagle3">EAGLE-3</option>
@@ -1323,7 +1336,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
(normalizeLoadFlags). -->
<div class="pe-row">
<span class="pe-row-l">Chargement<span class="pe-sub" id="s-loadmode-sub"></span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-loadmode" onchange="eaSetLoadMode(this.value)">
<span class="pe-row-c"><span class="pe-selc"><select id="s-loadmode" onchange="eaSetLoadMode(this.value); refreshMoeSoon()">
<option value="">auto</option>
<option value="mmap">mmap</option>
<option value="none">none</option>
@@ -1332,6 +1345,17 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<option value="dio">dio</option>
</select></span></span>
</div>
<!-- Mode résident (none, mlock, dio, mmap+mlock) avec trop de poids en
RAM : l'avertissement du lancement (loadModeRisk), ou son refus. -->
<div class="pe-row stack" id="s-loadmode-warn-row" style="display:none">
<div class="pe-note" id="s-loadmode-warn" style="color:var(--warn)"></div>
</div>
<!-- Optimiseur sans perte (backend_tune*.go) : essais sur un moteur
privé, mesurés au bench complet. Rien n'est écrit sans un clic. -->
<div class="pe-row" id="m-tune-row">
<span class="pe-row-l">Optimiseur<span class="pe-sub" id="m-tune-sub">lots, threads, marges --fit… mesurés sur un moteur privé · rien n'est écrit sans ton accord</span></span>
<span class="pe-row-c"><button class="pe-link" onclick="openTuneModal()">Optimiser…</button></span>
</div>
</div>
</div>
@@ -1399,6 +1423,24 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
</div>
</div>
<!-- Optimiseur sans perte (29-tune.js) : au-dessus de l'éditeur de preset, d'où
il s'ouvre. Les options cochées ici ne s'enregistrent nulle part. -->
<div id="tune-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:40;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeTuneModal()">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:640px;display:flex;flex-direction:column;max-height:90vh">
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
<strong>Optimiser le preset</strong>
<button onclick="closeTuneModal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
</div>
<div id="tune-body" style="padding:16px 18px;display:flex;flex-direction:column;gap:10px;overflow:auto"></div>
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px;flex-wrap:wrap">
<button id="tune-cancel" onclick="cancelTuneUI()" style="display:none">annuler l'optimisation</button>
<button id="tune-copy" onclick="applyTuneUI('copy')" style="display:none">enregistrer dans une copie</button>
<button id="tune-apply" onclick="applyTuneUI('preset')" style="display:none;border-color:var(--accent);color:var(--accent)">appliquer à ce preset…</button>
<button id="tune-start" onclick="startTuneUI()" style="border-color:var(--accent);color:var(--accent)">lancer</button>
<button onclick="closeTuneModal()">fermer</button>
</div>
</div>
</div>
<script src="marked.min.js"></script>
<script>
@@JS@@
+4
View File
@@ -34,8 +34,12 @@ let pendingPreset = 0;
// redessin et la puce semblait clignoter en boucle. On ne les rejoue que quand la
// sélection CHANGE vraiment.
let paintedSel = '';
// Dernière liste lue : l'éditeur y voit si le preset ouvert est celui en
// service (ligne « Optimiser », qui ne mesure que lui).
let presetsLast = [];
async function loadPresets(){
const p=await jget('/api/presets');
presetsLast = Array.isArray(p) ? p : [];
// Bascule terminée : le preset visé est devenu l'actif, on éteint l'attente.
if(pendingPreset && p[pendingPreset-1] && p[pendingPreset-1].active) pendingPreset = 0;
const act = p.find(x=>x.active);
+155 -11
View File
@@ -732,9 +732,11 @@ function renderGpu(){
// Mémoire à 0 = le moteur n'a pas pu la lire (carte déjà saturée par le
// modèle en cours) : on n'affiche pas « 0,0 Go », qui serait un mensonge.
const gb = d.total_mib > 0 ? ' · ' + (d.total_mib/1024).toFixed(1).replace('.', ',') + ' Go' : '';
const link = gpuLinkText(d);
return '<div class="pe-row">'
+ '<span class="pe-row-l">' + gpuEsc(gpuLabel(d))
+ '<span class="pe-sub">' + gpuEsc(d.id) + gb + '</span></span>'
+ '<span class="pe-sub" title="' + gpuEsc(gpuLinkTip(d)) + '">' + gpuEsc(d.id) + gb
+ (link ? ' · ' + gpuEsc(link) : '') + '</span></span>'
+ '<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" value="' + gpuEsc(d.id) + '"'
+ (sel.includes(d.id) ? ' checked' : '') + ' onchange="onGpuPick()"><span class="pe-sl"></span></label></span>'
+ '</div>';
@@ -744,10 +746,83 @@ function renderGpu(){
document.getElementById('m-gpu-note').textContent = sel.length === gpuDevices.length
? 'Toutes les cartes sont utilisées.'
: 'Ce modèle n\'utilisera que : ' + sel.map(id => gpuLabel(gpuById(id))).join(', ') + '.';
const guide = document.getElementById('m-gpu-guide');
if(guide){ guide.innerHTML = sel.length > 1 ? placementGuideHtml(sel) : ''; guide.hidden = sel.length < 2; }
group.hidden = false;
if(sel.length > 1) paintSplit();
}
// Liaison PCIe d'une carte (nvidia-smi, joint par nom côté serveur, CUDA
// seulement) : le MAXIMUM, seul parlant — l'actuel descend au repos.
function gpuLinkText(d){
const l = d && d.link;
if(!l || !(l.gen_max || l.width_max)) return '';
return 'PCIe' + (l.gen_max ? ' ' + l.gen_max : '') + (l.width_max ? ' ×' + l.width_max : '');
}
function gpuLinkTip(d){
const l = d && d.link;
if(!l) return '';
const t = [];
if(l.gen || l.width) t.push('liaison actuelle : PCIe ' + (l.gen||'?') + ' ×' + (l.width||'?') + ' (réduite au repos, normale sous charge)');
if(l.mem_clock_mhz) t.push('horloge mémoire max : ' + l.mem_clock_mhz + ' MHz');
return t.join('\n');
}
// Guide de placement entre cartes inégales. CONSEIL seulement : rien n'est
// écrit dans le preset sans un clic, et Loki ne change jamais l'ordre ni les
// marges d'office. Deux règles du moteur, qui peuvent s'opposer :
// - --fit (placement auto) remplit les cartes en partant de la DERNIÈRE, qui
// porte aussi la couche de sortie : un modèle dense décode plus vite si la
// carte rapide est en dernier, ou si la lente garde une marge plus large ;
// - les experts MoE restés en RAM sont recopiés, au prefill, vers la PREMIÈRE
// carte : à elle la liaison PCIe la plus large.
// « Première » et « dernière » s'entendent dans l'ordre du moteur, --device
// compris — pas « CUDA0 ».
function placementGuideHtml(sel){
const name = id => gpuEsc(gpuLabel(gpuById(id)));
const order = sel.map((id, i) => (i+1) + '. ' + name(id)
+ (gpuLinkText(gpuById(id)) ? ' (' + gpuEsc(gpuLinkText(gpuById(id))) + ')' : '')).join(' → ');
const parts = ['<b>Ordre vu par le moteur</b> : ' + order
+ (sel.length === 2 ? ' · <button class="pe-link" onclick="reverseGpuOrder()">inverser l\'ordre</button>' : '')];
parts.push('• Modèle dense en placement auto : --fit remplit d\'abord la <b>dernière</b> carte, qui porte aussi '
+ 'la couche de sortie — la plus rapide en dernier, ou une marge FIT_TARGET plus large sur la lente '
+ '(ex. FIT_TARGET=1024,3072 quand la lente est seconde ; 1024 au minimum).');
parts.push('• Experts MoE sur CPU : le prefill les recopie vers la <b>première</b> carte — à elle la liaison PCIe la plus large.');
const ft = cfgReadKey('FIT_TARGET');
if(ft){
const v = ft.split(/[,/]/).map(s => s.trim());
parts.push('FIT_TARGET : ' + (v.length === 1 ? gpuEsc(v[0]) + ' Mio sur chaque carte'
: sel.map((id, i) => name(id) + ' ' + gpuEsc(v[i] || '1024') + ' Mio').join(' · ')));
}
if(eaGetValued('--tensor-split'))
parts.push('--tensor-split fixe la répartition : --fit et FIT_TARGET ne jouent pas.');
const cvd = cfgReadKey('CUDA_VISIBLE_DEVICES');
if(cvd) parts.push('CUDA_VISIBLE_DEVICES=' + gpuEsc(cvd) + ' dans ce preset : l\'ordre réel peut différer de cette '
+ 'liste, établie avec la sélection de la machine — et les marges FIT_TARGET suivent l\'ordre réel.');
parts.push('Les deux règles peuvent s\'opposer : à départager par la mesure, preset par preset. Loki ne bascule pas '
+ 'le moteur tout seul pour comparer (deux rechargements complets, et un ordre inversé peut manquer de VRAM sur la '
+ 'petite carte) : duplique le preset, inverse l\'ordre dans la copie, puis « bench complet » sur chacun. '
+ 'Vérifie « offloaded N/N layers » au journal.');
return parts.join('<br>');
}
// Inverse l'ordre des deux cartes (--device), et la répartition avec elles.
// Geste explicite de l'utilisateur, écrit dans le preset édité seulement.
function reverseGpuOrder(){
const sel = selectedGpuIds();
if(sel.length !== 2) return;
const rev = sel.slice().reverse();
eaSetValued('--device', gpuNaturalOrder(rev) ? '' : rev.join(','));
const ts = eaGetValued('--tensor-split');
if(ts) eaSetValued('--tensor-split', ts.split(',').reverse().join(','));
renderGpu();
toast('ordre : ' + rev.map(id => gpuLabel(gpuById(id))).join(' → '));
}
// Toutes les cartes, dans l'ordre du moteur : aucune contrainte à écrire.
function gpuNaturalOrder(ids){
return ids.length === gpuDevices.length && ids.every((id, i) => id === gpuDevices[i].id);
}
function gpuById(id){ return gpuDevices.find(d => d.id === id) || {id}; }
// Nom lisible : « NVIDIA GeForce RTX 5060 Ti » → « RTX 5060 Ti ». CUDA0/Vulkan1
// ne disent rien à personne ; le numéro reste en sous-titre pour ceux qui
@@ -865,9 +940,13 @@ function resetSplit(){
function onGpuPick(){
const ids = [...document.querySelectorAll('#m-gpu-list input[type=checkbox]:checked')].map(c => c.value);
if(!ids.length){ toast('gardez au moins une carte'); renderGpu(); return; }
// Toutes cochées = pas de contrainte : on retire le flag plutôt que de figer
// des noms de device qui changeraient avec le moteur.
eaSetValued('--device', ids.length === gpuDevices.length ? '' : ids.join(','));
// L'ordre déjà choisi (« inverser l'ordre ») est gardé pour les cartes restées
// cochées ; les nouvelles viennent à la suite, dans l'ordre du moteur.
const prev = eaGetValued('--device').split(',').filter(id => ids.includes(id));
const order = prev.concat(ids.filter(id => !prev.includes(id)));
// Toutes cochées dans l'ordre du moteur = pas de contrainte : on retire le
// flag plutôt que de figer des noms de device qui changeraient avec le moteur.
eaSetValued('--device', gpuNaturalOrder(order) ? '' : order.join(','));
// Une répartition écrite pour N cartes n'a plus de sens pour N-1.
if(ids.length < 2) eaSetValued('--tensor-split', '');
renderGpu();
@@ -939,6 +1018,14 @@ function populateSettings(){
set('s-ubatch', cfgReadKey('UBATCH'));
set('s-cram', cfgReadKey('CACHE_RAM'));
refreshCacheRAM();
refreshMoe();
// L'optimiseur mesure le preset EN SERVICE (local) : sa ligne n'apparaît que
// dans l'éditeur de celui-ci, pas d'un autre preset ni d'un preset neuf.
const tuneRow = document.getElementById('m-tune-row');
if(tuneRow){
const act = (typeof presetsLast !== 'undefined' ? presetsLast : []).find(x => x.active);
tuneRow.style.display = (act && !act.external && editingKey && act.id === editingKey) ? '' : 'none';
}
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
set('s-kv', cfgReadKey('KV_TYPE'));
syncKVSub();
@@ -1030,8 +1117,60 @@ async function refreshCacheRAM(){
const fixed = /^fixé/.test(r.why || '');
el.placeholder = fixed ? (r.mib < 0 ? 'sans limite' : r.mib === 0 ? 'coupé' : String(r.mib))
: 'auto · ' + (r.mib > 0 ? r.mib : r.default);
// SIDE_SLOT : la VRAM du second slot, ou la raison du refus au lancement.
const side = r.side ? (r.side.on ? ' · SIDE_SLOT : +' + r.side.vramMiB + ' Mio de VRAM'
: ' · SIDE_SLOT refusé : ' + r.side.why) : '';
if(sub) sub.textContent = 'RAM hôte, copie exacte du contexte · jamais la VRAM' +
(r.why ? ' · ' + r.why : '') + (r.mib > 0 || fixed ? '' : ' · défaut du moteur');
(r.why ? ' · ' + r.why : '') + (r.mib > 0 || fixed ? '' : ' · défaut du moteur') + side;
}
// Avis MoE et mode de chargement, calculés côté serveur comme au lancement
// (backend_serve_moe.go) : experts placés à la main, UBATCH conseillé, moteur
// sans --fit, chargement résident trop gros pour la RAM. Des avis seulement :
// rien n'est écrit dans le preset. Une demande en vol ; la dernière gagne.
let moeSeq = 0, moeTimer = null, moeChanges = null;
function refreshMoeSoon(){ clearTimeout(moeTimer); moeTimer = setTimeout(refreshMoe, 400); }
async function refreshMoe(){
const row = document.getElementById('m-moe-row'), note = document.getElementById('m-moe-note');
const dup = document.getElementById('m-moe-dup');
const lrow = document.getElementById('s-loadmode-warn-row'), lw = document.getElementById('s-loadmode-warn');
if(!row) return;
const seq = ++moeSeq;
let r = {};
try{ r = await jpost('/api/preset/moe', {content: document.getElementById('m-content').value}); }
catch(_){ r = {}; }
if(seq !== moeSeq) return;
const ap = r.ok ? r.autoplace : null;
const parts = (r.ok && r.notes) ? r.notes.slice() : [];
if(ap && !ap.ok) parts.push('placement auto impossible : ' + ap.why);
note.textContent = parts.join(' · ');
moeChanges = (ap && ap.ok) ? (ap.changes || []) : null;
dup.hidden = !moeChanges;
row.style.display = (parts.length || moeChanges) ? '' : 'none';
if(lrow && lw){
lw.textContent = (r.ok && r.load) || '';
lrow.style.display = lw.textContent ? '' : 'none';
}
}
// « Dupliquer en placement auto » : une COPIE du preset tel qu'affiché, sans
// les experts placés à la main (--fit les répartit carte par carte), UBATCH
// 2048 / BATCH 4096, NGL auto, chargement mmap, CTX et cache KV intacts. Le
// preset d'origine ne bouge pas : c'est le repli si --fit échoue.
async function dupAutoPlacement(){
if(!moeChanges) return;
const msg = 'Créer une COPIE de ce preset en placement automatique (--fit place les experts carte par carte) :\n\n'
+ moeChanges.map(c => '• ' + c).join('\n')
+ '\n\nLe preset d\'origine reste tel quel : c\'est le repli. Après la bascule sur la copie, le journal doit dire '
+ '« successfully fit params » — « failed to fit » met tous les experts sur GPU : revenir à l\'original. '
+ 'Puis comparer les deux au « bench complet ».';
if(!await askConfirm(msg, {title:'Placement auto', okText:'Créer la copie'})) return;
let r = {};
try{
r = await jpost('/api/preset/autoplace', {id: editingKey || '', name: document.getElementById('m-name').value.trim(),
content: document.getElementById('m-content').value});
}catch(e){ r = {ok:false, error:e.message}; }
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('copie créée : ' + r.name);
KINDS.preset.reload();
}
function syncKVSub(){
const el = document.getElementById('s-kv-sub');
@@ -1105,10 +1244,14 @@ function syncLoadModeSub(mode){
// « auto » et « MTP » passent par la clé SPEC : Loki cherche la tête MTP dans
// le fichier et pose les garde-fous au lancement. Les autres types restent des
// --spec-type d'EXTRA_ARGS, qui ont toujours le dernier mot côté serveur.
// « ngram » et « mtp+ngram » aussi : Loki écrit lui-même les bornes de
// ngram-mod et refuse là où les longs lots de vérification coûteraient cher.
function specFromKey(v){
v = String(v||'').trim().toLowerCase();
return v === 'auto' ? 'auto' : v === 'mtp' ? 'draft-mtp' : '';
if(v === 'ngram+mtp') v = 'mtp+ngram';
return v === 'auto' ? 'auto' : v === 'mtp' ? 'draft-mtp' : (v === 'ngram' || v === 'mtp+ngram') ? v : '';
}
const SPEC_KEY_VALUES = {'auto': 'auto', 'draft-mtp': 'mtp', 'ngram': 'ngram', 'mtp+ngram': 'mtp+ngram'};
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
// par des virgules ; une valeur composée (ou un type sorti après cette version
// de loki) ne correspondrait à aucune option et serait silencieusement effacée
@@ -1133,20 +1276,21 @@ function syncSpecRow(){
const row = document.getElementById('s-spec-n-row');
if(!sel || !row) return;
const on = !!sel.value;
row.style.display = on ? '' : 'none';
// SPEC=ngram n'a pas de brouillon : un --spec-draft-n-max le ferait refuser.
row.style.display = on && sel.value !== 'ngram' ? '' : 'none';
// Le brouillon ne sert qu'à SPEC (auto / MTP) ; un brouillon déjà posé reste
// visible pour qu'on puisse le retirer.
const dr = document.getElementById('s-draft-row');
if(dr) dr.style.display = (sel.value === 'auto' || sel.value === 'draft-mtp' || cfgReadKey('MODEL_DRAFT')) ? '' : 'none';
if(dr) dr.style.display = (sel.value === 'auto' || sel.value === 'draft-mtp' || sel.value === 'mtp+ngram' || cfgReadKey('MODEL_DRAFT')) ? '' : 'none';
return on;
}
function onSpecType(){
const v = document.getElementById('s-spec').value;
const viaKey = v === 'auto' || v === 'draft-mtp';
const viaKey = v in SPEC_KEY_VALUES;
// « non » écrit SPEC=off : explicite, et identique au défaut du serveur.
cfgWriteKey('SPEC', v === 'auto' ? 'auto' : v === 'draft-mtp' ? 'mtp' : v ? '' : 'off');
cfgWriteKey('SPEC', viaKey ? SPEC_KEY_VALUES[v] : v ? '' : 'off');
eaSetValued('--spec-type', viaKey ? '' : v);
if(!v){
if(!v || v === 'ngram'){
eaSetValued('--spec-draft-n-max', '');
document.getElementById('s-spec-n').value = '';
}
+1 -1
View File
@@ -263,7 +263,7 @@ function renderStats(el, s){
}
// Ce qui s'est intercalé avant une perte de cache (lost_after, perf_log.go),
// dit en clair : les identifiants internes n'ont rien à faire dans l'interface.
const PERF_KIND={main:'tour', subagent:'sous-agent', verify:'vérification', task:'tâche', compact:'compaction', bench:'bench', foreign:'client /v1'};
const PERF_KIND={main:'tour', subagent:'sous-agent', verify:'vérification', task:'tâche', compact:'compaction', bench:'bench', foreign:'client /v1', prewarm:'préchauffage'};
// Milliers séparés par une espace fine insécable : « 41 230 », pas « 41230 ».
function nfmt(n){ return String(Math.round(n||0)).replace(/\B(?=(\d{3})+(?!\d))/g,'\u202f'); }
// --- Compteurs de bulle -----------------------------------------------------
+177
View File
@@ -0,0 +1,177 @@
// Optimiseur sans perte (backend_tune*.go). Il tourne côté serveur, en tâche de
// fond : la modale lance, suit, montre les mesures — et n'écrit RIEN sans un
// clic (copie du preset, ou ce preset après confirmation du diff).
let tunePoll = null, tuneState = 'form';
// Étapes de base (tuneStages côté serveur) ; placement et opt-in ont leur case.
const TUNE_STAGES = [['lots','micro-lot et lot'], ['threads','threads'], ['delestage','délestage'], ['marges','marges --fit'], ['files','files CUDA']];
function closeTuneModal(){ clearTimeout(tunePoll); hideModal('tune-modal'); }
function tuneBtns(state){
tuneState = state;
const show = (id, on) => { const b = document.getElementById(id); if(b) b.style.display = on ? '' : 'none'; };
show('tune-start', state !== 'running');
const sb = document.getElementById('tune-start');
if(sb) sb.textContent = state === 'form' ? 'lancer' : 'nouvelle mesure…';
show('tune-cancel', state === 'running');
show('tune-copy', state === 'result');
show('tune-apply', state === 'result');
}
function tuneSec(s){
s = Math.max(0, Math.round(s||0));
return s >= 60 ? Math.floor(s/60) + ' min ' + String(s%60).padStart(2,'0') + ' s' : s + ' s';
}
async function openTuneModal(){
showModal('tune-modal');
const body = document.getElementById('tune-body');
body.innerHTML = '<div class="muted">…</div>';
let st = {};
try{ st = await jget('/api/tune/status'); }catch(_){}
// Une mesure finie (modale fermée pendant qu'elle tournait) : son résultat
// d'abord, avec ses boutons — « nouvelle mesure… » ramène au formulaire.
if(st.running || st.applying || st.apply || (st.result && st.result.preset_id === editingKey)){ tuneWatch(); return; }
let last = null;
if(editingKey){ try{ last = await jget('/api/tune/last?id=' + encodeURIComponent(editingKey)); }catch(_){} }
tuneForm(last && last.ok ? last : null, st.elsewhere);
}
function tuneForm(last, elsewhere){
const body = document.getElementById('tune-body');
let h = '<div class="pe-note">Des essais sur un moteur <b>privé</b> (127.0.0.1), chacun mesuré au bench complet : '
+ 'micro-lot et lot, threads (poids sur CPU), seuil de délestage, marges <code>--fit</code> et files CUDA (2 cartes ou plus). '
+ 'Jamais touchés : modèle, contexte, cache KV, raisonnement, échantillonnage, slots. Le moteur principal est arrêté pendant la mesure '
+ '(chat et tâches en pause), puis relancé. Rien n\'est écrit sans ton accord.</div>'
+ '<div class="pe-note muted">Mesure la version <b>enregistrée</b> du preset en service.</div>'
+ '<div style="display:flex;gap:10px;flex-wrap:wrap;font-size:12px"><span class="muted">Étapes :</span>'
+ TUNE_STAGES.map(s => '<label style="display:flex;gap:4px;align-items:center"><input type="checkbox" class="tune-stage" value="' + s[0] + '" checked> ' + s[1] + '</label>').join('')
+ '</div>'
+ '<label style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" id="tune-placement"> <span>Inclure le placement'
+ '<span class="muted" style="display:block;font-size:11px">--fit au lieu des experts MoE ou du --tensor-split placés à la main : réécrit EXTRA_ARGS, confirmé à part</span></span></label>'
+ '<label style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" id="tune-optin"> <span>Inclure les options opt-in'
+ '<span class="muted" style="display:block;font-size:11px">SPEC (sortie inchangée, jugée sur prose et code), SPEC_N_MAX, CUDA_GRAPH_OPT (expérimental), --backend-sampling</span></span></label>'
+ '<label style="display:flex;gap:8px;align-items:center">Budget <input type="number" id="tune-budget" class="pe-val" min="5" max="360" step="5" value="30" style="width:80px"> minutes'
+ '<span class="muted" style="font-size:11px">— au-delà, résultat partiel</span></label>';
if(elsewhere) h += '<div class="pe-note" style="color:var(--warn)">Une optimisation tourne déjà (' + escHtml(elsewhere) + ').</div>';
if(last && last.result){
const r = last.result;
const when = new Date(r.at*1000).toLocaleString();
h += '<div class="pe-note">Dernière optimisation : ' + escHtml(when) + (r.best ? ' — ' + escHtml(r.best) + ', −' + (r.gain*100).toFixed(1) + ' % par tour' : ' — rien de mieux trouvé') + '</div>';
if(last.engine_changed) h += '<div class="pe-note" style="color:var(--warn)">Le moteur a changé depuis (' + escHtml(last.engine_changed) + ') : les meilleurs réglages ont pu bouger — relance l\'optimisation.</div>';
if(last.preset_changed) h += '<div class="pe-note muted">Le preset a été modifié depuis cette mesure.</div>';
}
body.innerHTML = h;
tuneBtns('form');
}
async function startTuneUI(){
if(tuneState !== 'form'){
let last = null;
if(editingKey){ try{ last = await jget('/api/tune/last?id=' + encodeURIComponent(editingKey)); }catch(_){} }
tuneForm(last && last.ok ? last : null);
return;
}
const placement = !!(document.getElementById('tune-placement')||{}).checked;
const optin = !!(document.getElementById('tune-optin')||{}).checked;
const budget = parseInt((document.getElementById('tune-budget')||{}).value, 10) || 30;
// Toutes cochées = toutes (liste vide) ; aucune = seulement placement / opt-in.
const boxes = [...document.querySelectorAll('.tune-stage')];
const picked = boxes.filter(b => b.checked).map(b => b.value);
const stages = picked.length === boxes.length ? [] : (picked.length ? picked : ['aucune']);
const msg = 'Le moteur principal va être ARRÊTÉ pendant jusqu\'à ' + budget + ' minutes : chat, tâches et clients /v1 attendent. '
+ 'Chaque essai recharge le modèle. Lancer l\'optimisation ?';
if(!await askConfirm(msg, {title:'Optimiser', okText:'Lancer'})) return;
let r;
try{ r = await jpost('/api/tune', {placement, optin, budget_min: budget, stages}); }catch(e){ r = {ok:false, error:e.message}; }
if(!r.ok){ document.getElementById('tune-body').insertAdjacentHTML('beforeend', '<div style="color:var(--err)">' + escHtml(r.error||'?') + '</div>'); return; }
tuneWatch();
}
async function cancelTuneUI(){ try{ await jpost('/api/tune/cancel', {}); }catch(_){} }
function tuneTrialsHTML(trials){
if(!trials || !trials.length) return '';
let h = '<table style="width:100%;font-size:12px;border-collapse:collapse"><tr class="muted"><td>étape</td><td>essai</td>'
+ '<td style="text-align:right">tour</td><td style="text-align:right">VRAM libre</td><td>statut</td></tr>';
for(const t of trials){
const color = t.status === 'retenu' ? 'var(--ok)' : (t.status === 'échec' || t.status === 'écarté') ? 'var(--warn)' : 'inherit';
const runs = (t.runs||[]);
let tip = '';
if(runs.length){
const r0 = runs[0];
tip = 'prose ' + (r0.prose_pp||0).toFixed(0) + ' / ' + (r0.prose_tg||0).toFixed(1) + ' t/s';
if(r0.depth) tip += ' · à ' + r0.depth + ' jetons : froid ' + (r0.cold_pp||0).toFixed(0) + ', tours ' + (r0.cached_pp||0).toFixed(0) + ', decode ' + (r0.tg||0).toFixed(1) + ' t/s';
tip += ' · ' + runs.length + ' passage(s)';
}
if(t.offloaded) tip += ' · couches ' + t.offloaded;
h += '<tr title="' + escHtml(tip) + '"><td class="muted" style="padding:3px 6px 3px 0">' + escHtml(t.stage) + '</td>'
+ '<td>' + escHtml(t.label) + '</td>'
+ '<td style="text-align:right">' + (t.turn_sec ? t.turn_sec.toFixed(1) + ' s' : '—') + '</td>'
+ '<td style="text-align:right">' + (t.headroom_mib >= 0 ? t.headroom_mib + ' Mio' : '—') + '</td>'
+ '<td style="color:' + color + ';padding-left:6px">' + escHtml(t.status) + (t.why ? ' <span class="muted">· ' + escHtml(t.why) + '</span>' : '') + '</td></tr>';
}
return h + '</table>';
}
function tuneResultHTML(r){
let h = '<div class="pe-note muted">Score : durée d\'un tour type — ' + (r.weights.k).toFixed(0) + ' jetons lus (cache repris), '
+ (r.weights.g).toFixed(0) + ' écrits, ' + (r.weights.f_cold*100).toFixed(0) + ' % des tours relus à froid à ' + r.depth + ' jetons ('
+ escHtml(r.weights.from) + '). Un gain compte au-delà de 3 % et de l\'écart entre passages. Moteur ' + escHtml(r.engine||'?') + '.</div>';
h += tuneTrialsHTML([r.baseline].concat(r.trials||[]));
for(const s of (r.skipped||[])) h += '<div class="muted" style="font-size:11px">sauté — ' + escHtml(s) + '</div>';
for(const n of (r.notes||[])) h += '<div style="font-size:11px;color:var(--warn)">' + escHtml(n) + '</div>';
if(r.partial) h += '<div style="font-size:11px;color:var(--warn)">résultat partiel : ' + escHtml(r.partial) + '</div>';
if(r.best){
h += '<div style="margin-top:6px"><b style="color:var(--ok)">Meilleur : ' + escHtml(r.best) + '</b> — −' + (r.gain*100).toFixed(1) + ' % par tour</div>'
+ '<div class="pe-note" style="white-space:pre-wrap;font-family:var(--mono,monospace);font-size:11px">' + escHtml((r.changes||[]).join('\n')) + '</div>';
if(r.placement) h += '<div class="pe-note" style="color:var(--warn)">Ce résultat réécrit EXTRA_ARGS (placement) : à confirmer à part.</div>';
} else {
h += '<div style="margin-top:6px">La configuration actuelle reste la meilleure : rien à changer.</div>';
}
return h;
}
function tuneWatch(){
clearTimeout(tunePoll);
const body = document.getElementById('tune-body');
const tick = async () => {
let st;
try{ st = await jget('/api/tune/status'); }
catch(_){ tunePoll = setTimeout(tick, 3000); return; }
if(st.running){
tuneBtns('running');
const eta = st.eta_sec >= 0 ? ' · reste ~' + tuneSec(st.eta_sec) : '';
body.innerHTML = '<div class="muted" style="text-align:center">⏳ ' + escHtml(st.phase||'…') + '<br>' + tuneSec(st.elapsed_sec) + eta
+ '<br><span style="font-size:11px">moteur principal arrêté · chat et tâches en pause</span></div>' + tuneTrialsHTML(st.trials);
tunePoll = setTimeout(tick, 2000);
return;
}
let h = '';
if(st.error) h += '<div style="color:var(--err)">' + escHtml(st.error) + '</div>';
if(st.result) h += tuneResultHTML(st.result);
const a = st.apply;
if(a){
h += '<div class="pe-note">' + (a.log||[]).map(escHtml).join('<br>') + '</div>';
if(a.message) h += '<div style="color:var(--ok)">' + escHtml(a.message) + '</div>';
if(a.error) h += '<div style="color:var(--err)">' + escHtml(a.error) + '</div>';
}
body.innerHTML = h || '<div class="muted">rien à montrer</div>';
const canApply = !!(st.result && st.result.best) && !st.applying && !(a && a.message);
tuneBtns(canApply ? 'result' : 'done');
if(st.applying){ tunePoll = setTimeout(tick, 2000); }
};
tick();
}
async function applyTuneUI(target){
let st = {};
try{ st = await jget('/api/tune/status'); }catch(_){}
const r = st.result;
if(!r || !r.best) return;
const diff = (r.changes||[]).map(c => '• ' + c).join('\n');
if(target === 'copy'){
if(!await askConfirm('Créer une COPIE de « ' + r.preset_name + ' » avec :\n\n' + diff + '\n\nLe preset d\'origine ne bouge pas.',
{title:'Enregistrer dans une copie', okText:'Créer la copie'})) return;
} else {
if(!await askConfirm('Réécrire « ' + r.preset_name + ' » (sauvegardé avant) avec :\n\n' + diff
+ '\n\nLe moteur redémarre, puis une sonde vérifie un prompt à ' + r.depth + ' jetons ; en cas d\'échec, l\'ancienne version est rétablie.',
{title:'Appliquer à ce preset', okText:'Appliquer'})) return;
if(r.placement && !await askConfirm('Ce résultat RÉÉCRIT EXTRA_ARGS (placement des experts ou --tensor-split retirés pour --fit). Confirmer ?',
{title:'Placement', okText:'Confirmer'})) return;
}
let res;
try{ res = await jpost('/api/tune/apply', {target}); }catch(e){ res = {ok:false, error:e.message}; }
if(!res.ok){ toast('erreur : ' + (res.error||'')); return; }
if(target === 'copy'){ toast(res.message || 'copie créée'); KINDS.preset.reload(); return; }
tuneWatch();
}
+96 -4
View File
@@ -96,8 +96,10 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
// qui exige un fork de llama.cpp. Renvoie "" quand le dernier chargement a
// réussi ou qu'aucune tentative n'est visible (évite les faux positifs sur un
// service simplement arrêté).
func modelLoadError() string {
log := serviceLogTail(200)
func modelLoadError() string { return modelLoadErrorFrom(serviceLogTail(200)) }
// modelLoadErrorFrom : modelLoadError sur un journal donné (tests).
func modelLoadErrorFrom(log string) string {
if log == "" {
return ""
}
@@ -105,7 +107,9 @@ func modelLoadError() string {
// On ne considère que ce qui suit la DERNIÈRE tentative de chargement.
start := 0
for i, l := range lines {
if strings.Contains(l, "loading model") || strings.Contains(l, "load_model") {
// Un lancement refusé par LOAD_GUARD (backend_serve_moe.go) s'arrête
// avant tout chargement : c'est lui, la dernière tentative.
if strings.Contains(l, "loading model") || strings.Contains(l, "load_model") || strings.Contains(l, loadGuardMarker) {
start = i
}
}
@@ -120,6 +124,13 @@ func modelLoadError() string {
strings.Contains(low, "failed to initialize speculative decoding context"):
// Avant l'échec générique : c'est le brouillon qui manque, pas le modèle.
reason = "le décodage spéculatif n'a pas pu démarrer (tête MTP ou modèle brouillon) — SPEC=off dans le preset"
case strings.Contains(low, "split_mode_tensor requires flash_attn"),
strings.Contains(low, "split_mode_tensor not implemented"):
// Les autres lignes qui citent SPLIT_MODE_TENSOR ne sont que des avis.
reason = "le mode tensor (-sm tensor) n'a pas pu démarrer — SPLIT_MODE=off dans le preset"
case strings.Contains(l, loadGuardMarker):
reason = "lancement refusé : trop de poids resteraient en RAM avec ce mode de chargement — " +
"--load-mode mmap dans le preset, ou LOAD_GUARD=off pour passer outre"
case strings.Contains(l, "has offset") && strings.Contains(l, "expected"):
reason = "format de quantification non reconnu par ce moteur"
case strings.Contains(low, "unknown model architecture"),
@@ -495,10 +506,75 @@ func handlePresetCacheRAM(w http.ResponseWriter, r *http.Request) {
return
}
mib, why := cacheRAMPreview(req.Content)
sendJSON(w, 200, map[string]any{"ok": true, "mib": mib, "default": engineCacheRAMDefault, "why": why})
out := map[string]any{"ok": true, "mib": mib, "default": engineCacheRAMDefault, "why": why}
// Second slot (SIDE_SLOT) : la VRAM qu'il coûterait, ou pourquoi il sera
// refusé. Absent sans la clé.
if on, extra, swhy := sideSlotPreview(req.Content); on || swhy != "" {
out["side"] = map[string]any{"on": on, "vramMiB": extra, "why": swhy}
}
sendJSON(w, 200, out)
}
// handlePresetMoE : avis MoE et mode de chargement pour le preset en cours
// d'édition (voir backend_serve_moe.go), et ce que ferait « Dupliquer en
// placement auto » — sans rien écrire.
func handlePresetMoE(w http.ResponseWriter, r *http.Request) {
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
notes, load := moePreview(req.Content)
out := map[string]any{"ok": true, "notes": notes, "load": load}
// L'assistant n'est proposé qu'à un preset qui place des experts à la
// main ; « rien à migrer » ne s'affiche pas.
if cfg := parseEnv(req.Content); !isExternalConfig(cfg) && cpuExperts(splitArgs(cfg["EXTRA_ARGS"]), nil) != "" {
if _, changes, err := autoPlacementPreset(req.Content); err != nil {
out["autoplace"] = map[string]any{"ok": false, "why": err.Error()}
} else {
out["autoplace"] = map[string]any{"ok": true, "changes": changes}
}
}
sendJSON(w, 200, out)
}
// handlePresetAutoPlace crée la copie en placement auto du preset édité (son
// contenu tel qu'affiché), sous un nouveau nom. Le preset d'origine n'est pas
// touché, et la copie n'est pas activée : c'est à l'utilisateur de basculer,
// puis de comparer (bench complet).
func handlePresetAutoPlace(w http.ResponseWriter, r *http.Request) {
if !postOnly(w, r) {
return
}
var req struct {
ID string `json:"id"`
Name string `json:"name"`
Content string `json:"content"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
out, changes, err := autoPlacementPreset(req.Content)
if err != nil {
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
return
}
name := firstNonEmpty(strings.TrimSpace(req.Name), presetDisplayName(req.Content, req.ID), "preset") + " (placement auto)"
newID, err := SavePreset("", name, out)
if err != nil {
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": name, "changes": changes})
}
func handlePresetSave(w http.ResponseWriter, r *http.Request) {
if tuneDenyHTTP(w) {
return
}
var req presetSaveReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
@@ -637,6 +713,9 @@ func handleAgentToggle(w http.ResponseWriter, r *http.Request) {
// key?} l'écrit puis redémarre le service (llama-server lit --api-key au lancement).
func handleAPIKey(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
if tuneDenyHTTP(w) {
return
}
var req struct {
Action string `json:"action"`
Key string `json:"key"`
@@ -1234,6 +1313,9 @@ func handleMemDelete(w http.ResponseWriter, r *http.Request) {
// même contrat que handleSwitch. C'est ce qui rend le sélecteur de l'en-tête
// utilisable même sans avoir créé de preset.
func handleModelUse(w http.ResponseWriter, r *http.Request) {
if tuneDenyHTTP(w) {
return
}
var req struct {
Value string `json:"value"`
}
@@ -1261,6 +1343,11 @@ func handleModelUse(w http.ResponseWriter, r *http.Request) {
}
func handleSwitch(w http.ResponseWriter, r *http.Request) {
// Pendant une optimisation, la configuration active est celle qu'elle mesure
// et le moteur ne doit pas redémarrer (backend_tune_lock.go).
if tuneDenyHTTP(w) {
return
}
// id = identité stable du preset (nom de fichier). Le numéro seul est
// dangereux : si la liste a changé depuis l'affichage (réordonnée, preset
// ajouté ou supprimé depuis un autre appareil), la même position désigne un
@@ -1304,6 +1391,9 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
// SwitchToPreset bloquait la réponse pendant tout l'arrêt de llama-server plus
// les 2 s de vérification de checkStarted : côté UI, le clic paraissait mou et
// la sélection ne bougeait qu'au bout de plusieurs secondes, pour rien.
// SLOT_PERSIST : relevé avant que la configuration change, gardé juste
// avant l'arrêt du moteur (llm_slotpersist.go). Sans la clé : nil.
persist := slotPersistPrepare()
if err := applyPresetFile(target.Path); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
@@ -1317,6 +1407,7 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
fmt.Println(dim("[info] preset externe — arrêt du moteur local"))
go func() {
if serviceIsActive() {
persist.save()
if err := serviceAction("stop"); err != nil {
fmt.Printf("%s arrêt du moteur après bascule externe : %v\n", red("[ERREUR]"), err)
}
@@ -1327,6 +1418,7 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
}
fmt.Println(dim("[info] redémarrage du service..."))
go func() {
persist.save()
if err := serviceAction("restart"); err != nil {
fmt.Printf("%s redémarrage après bascule: %v\n", red("[ERREUR]"), err)
}
+45 -34
View File
@@ -115,50 +115,56 @@ func devPersistPut(key string, devs []map[string]any) {
}
}
// fillMissingMemory complète les mémoires que le moteur n'a pas su lire. Quand
// une carte est déjà saturée par le modèle en cours, llama.cpp annonce 0 Mio —
// l'UI n'avait alors rien à afficher pour elle, ce qui donnait une liste
// incohérente (une carte avec sa taille, l'autre sans).
// annotateDevices complète les cartes listées par le moteur avec ce que
// nvidia-smi sait d'elles (UNE lecture, detectGPUs). Fonction pure.
//
// On complète depuis nvidia-smi PAR CORRESPONDANCE DE NOM, et uniquement pour
// ça : la mémoire totale est une donnée matérielle fixe, indépendante du
// backend. L'ordre et les identifiants des devices, eux, appartiennent au
// moteur (CUDA0 et Vulkan0 ne désignent pas la même carte) et ne doivent jamais
// venir de nvidia-smi. Un nom en double (deux cartes identiques) rend la
// correspondance ambiguë : on préfère alors ne rien dire.
func fillMissingMemory(devs []map[string]any) {
missing := false
for _, d := range devs {
if n, _ := d["total_mib"].(int); n <= 0 {
missing = true
}
}
if !missing {
return
}
gpus, err := detectGPUs()
if err != nil {
return
}
totals := map[string]int{}
// Mémoire totale manquante : quand une carte est déjà saturée par le modèle en
// cours, llama.cpp annonce 0 Mio — l'UI n'avait alors rien à afficher pour
// elle, ce qui donnait une liste incohérente (une carte avec sa taille, l'autre
// sans). Liaison PCIe et horloge mémoire (clé « link ») : de quoi choisir
// l'ordre des cartes en connaissance de cause (voir le guide de placement de
// l'éditeur), jamais appliqué d'office.
//
// Tout passe PAR CORRESPONDANCE DE NOM : ce sont des données matérielles fixes,
// indépendantes du backend. L'ordre et les identifiants des devices, eux,
// appartiennent au moteur (CUDA0 et Vulkan0 ne désignent pas la même carte) et
// ne doivent jamais venir de nvidia-smi. Un nom en double (deux cartes
// identiques) rend la correspondance ambiguë : on préfère alors ne rien dire.
// La liaison ne s'attache qu'aux cartes CUDA : un moteur Vulkan énumère dans
// son propre ordre, et le conseil qui l'accompagne parle de CUDA.
func annotateDevices(devs []map[string]any, gpus []gpuInfo) {
byName := map[string]gpuInfo{}
dup := map[string]bool{}
for _, g := range gpus {
name := strings.TrimSpace(g.Name)
if _, seen := totals[name]; seen {
if _, seen := byName[name]; seen {
dup[name] = true
continue
}
if mb, err := strconv.Atoi(strings.TrimSpace(g.MemTotal)); err == nil {
totals[name] = mb
}
byName[name] = g
}
for _, d := range devs {
if n, _ := d["total_mib"].(int); n > 0 {
name, _ := d["name"].(string)
name = strings.TrimSpace(name)
g, ok := byName[name]
if !ok || dup[name] {
continue
}
name, _ := d["name"].(string)
if mb, ok := totals[strings.TrimSpace(name)]; ok && !dup[strings.TrimSpace(name)] {
d["total_mib"] = mb
if n, _ := d["total_mib"].(int); n <= 0 {
if mb, err := strconv.Atoi(strings.TrimSpace(g.MemTotal)); err == nil {
d["total_mib"] = mb
}
}
if id, _ := d["id"].(string); !strings.HasPrefix(id, "CUDA") {
continue
}
if g.LinkGenMax == 0 && g.LinkWidthMax == 0 && g.MemClockMax == 0 {
continue
}
d["link"] = map[string]any{
"gen": g.LinkGen, "gen_max": g.LinkGenMax,
"width": g.LinkWidth, "width_max": g.LinkWidthMax,
"mem_clock_mhz": g.MemClockMax,
}
}
}
@@ -238,7 +244,12 @@ func handleBackendDevices(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true, "devices": devs, "stale": true})
return
}
fillMissingMemory(devs)
// Une seule lecture nvidia-smi par énumération (et l'énumération est en
// cache dix minutes) : mémoire manquante et liaison PCIe ensemble. Sans
// nvidia-smi (Mac, AMD), rien n'est ajouté.
if gpus, gerr := detectGPUs(); gerr == nil {
annotateDevices(devs, gpus)
}
// Quand une carte est déjà saturée par le modèle en cours, le moteur peut
// annoncer 0 Mio de mémoire : c'est une lecture transitoire, on ne la fige
// pas dans le cache (sinon l'UI affiche « 0 Go » pendant dix minutes).
+36 -15
View File
@@ -44,32 +44,53 @@ warning: something else entirely
}
// Mémoire complétée : un device dont le moteur n'a pas su lire la taille (0 Mio,
// carte saturée) récupère celle de nvidia-smi par correspondance de nom. Deux
// cartes homonymes rendent la correspondance ambiguë : on ne complète pas.
func TestFillMissingMemoryParNom(t *testing.T) {
// carte saturée) récupère celle de nvidia-smi par correspondance de nom ; la
// liaison PCIe s'y ajoute, pour les cartes CUDA seulement.
func TestAnnotateDevicesParNom(t *testing.T) {
devs := []map[string]any{
{"id": "CUDA0", "name": "NVIDIA GeForce RTX 5060 Ti", "total_mib": 0, "free_mib": 0},
{"id": "CUDA1", "name": "NVIDIA GeForce GTX 1650", "total_mib": 3715, "free_mib": 3659},
}
// La table de correspondance est celle que construit fillMissingMemory ; on
// vérifie ici la règle métier sans dépendre d'un nvidia-smi présent.
totals := map[string]int{"NVIDIA GeForce RTX 5060 Ti": 16311}
dup := map[string]bool{}
for _, d := range devs {
if n, _ := d["total_mib"].(int); n > 0 {
continue
}
name, _ := d["name"].(string)
if mb, ok := totals[name]; ok && !dup[name] {
d["total_mib"] = mb
}
gpus := []gpuInfo{
{Name: "NVIDIA GeForce GTX 1650", MemTotal: "4096", LinkGen: 1, LinkGenMax: 3, LinkWidth: 16, LinkWidthMax: 16},
{Name: "NVIDIA GeForce RTX 5060 Ti", MemTotal: "16311", LinkGen: 5, LinkGenMax: 5, LinkWidth: 8, LinkWidthMax: 8, MemClockMax: 14001},
}
annotateDevices(devs, gpus)
if devs[0]["total_mib"] != 16311 {
t.Errorf("mémoire non complétée : %v", devs[0])
}
if devs[1]["total_mib"] != 3715 {
t.Errorf("mémoire déjà connue écrasée : %v", devs[1])
}
l, _ := devs[0]["link"].(map[string]any)
if l == nil || l["gen_max"] != 5 || l["width_max"] != 8 || l["mem_clock_mhz"] != 14001 {
t.Errorf("liaison absente ou fausse : %v", devs[0]["link"])
}
if l, _ := devs[1]["link"].(map[string]any); l == nil || l["width"] != 16 {
t.Errorf("liaison de la seconde carte : %v", devs[1]["link"])
}
}
// Deux cartes homonymes : correspondance ambiguë, rien n'est ajouté. Une carte
// Vulkan garde sa mémoire complétée mais pas de liaison.
func TestAnnotateDevicesAmbiguEtVulkan(t *testing.T) {
devs := []map[string]any{
{"id": "CUDA0", "name": "NVIDIA GeForce RTX 3060", "total_mib": 0},
{"id": "CUDA1", "name": "NVIDIA GeForce RTX 3060", "total_mib": 12288},
}
twins := []gpuInfo{
{Name: "NVIDIA GeForce RTX 3060", MemTotal: "12288", LinkGenMax: 4, LinkWidthMax: 16},
{Name: "NVIDIA GeForce RTX 3060", MemTotal: "12288", LinkGenMax: 3, LinkWidthMax: 4},
}
annotateDevices(devs, twins)
if devs[0]["total_mib"] != 0 || devs[0]["link"] != nil || devs[1]["link"] != nil {
t.Errorf("cartes homonymes annotées quand même : %v", devs)
}
vk := []map[string]any{{"id": "Vulkan0", "name": "NVIDIA GeForce RTX 3060", "total_mib": 0}}
annotateDevices(vk, twins[:1])
if vk[0]["total_mib"] != 12288 || vk[0]["link"] != nil {
t.Errorf("carte Vulkan : %v", vk[0])
}
}
// Le cache ne doit pas figer une lecture dégénérée (0 Mio) pendant 10 minutes.
+6
View File
@@ -182,6 +182,9 @@ func engineKeepAfterUpdate(bin, ancien string, logf func(string)) map[string]boo
// engineSwitchTo pointe BIN sur un moteur et redémarre le service s'il tournait.
func engineSwitchTo(bin string) error {
if err := tuneGuard(); err != nil {
return err
}
wasUp := serviceIsActive()
if wasUp {
lcPhase("arrêt du moteur…")
@@ -209,6 +212,9 @@ func engineSwitchTo(bin string) error {
// de l'image ({tag:"image"}). Aucun réseau : c'est la sortie de secours quand une
// mise à jour s'avère décevante.
func handleEngineUse(w http.ResponseWriter, r *http.Request) {
if tuneDenyHTTP(w) {
return
}
var req struct {
Tag string `json:"tag"`
}
+8
View File
@@ -86,6 +86,11 @@ func lcPhase(phase string) {
// startLcJob démarre un job (install ou update) si aucun n'est en cours.
func startLcJob(action string, run func()) error {
// Compiler, installer ou changer de moteur l'arrête et le relance : pas pendant
// une optimisation (backend_tune_lock.go).
if err := tuneGuard(); err != nil {
return err
}
lcMu.Lock()
defer lcMu.Unlock()
if lcCur != nil && lcCur.Running {
@@ -430,6 +435,9 @@ func lcRunPrebuilt() {
// service redémarre pour prendre le nouveau binaire. L'UI n'appelle ceci que
// quand la version cible existe déjà (sinon elle lance un job d'installation).
func handleLlamacppUse(w http.ResponseWriter, r *http.Request) {
if tuneDenyHTTP(w) {
return
}
var req struct {
Mode string `json:"mode"`
}
+11 -1
View File
@@ -139,6 +139,9 @@ func newWebMux() *http.ServeMux {
// Jeton de tentative du décodage spéculatif automatique : effacé dès que le
// moteur répond, sans dépendre d'une page ouverte (voir backend_serve_spec.go).
startSpecAttemptWatch()
// Optimisation interrompue (Loki tué en plein essai) : essai orphelin arrêté,
// moteur relancé s'il tournait avant (backend_tune_lock.go).
go tuneRecoverAtBoot()
mux := http.NewServeMux()
// Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la
// donnée et toutes les actions passent par /api/* qui, lui, exige la clé.
@@ -253,7 +256,9 @@ func newWebMux() *http.ServeMux {
api("/api/presets", handlePresets)
api("/api/preset", handlePreset)
api("/api/preset/save", handlePresetSave)
api("/api/preset/cacheram", handlePresetCacheRAM) // aperçu de --cache-ram pour le preset édité
api("/api/preset/cacheram", handlePresetCacheRAM) // aperçu de --cache-ram pour le preset édité
api("/api/preset/moe", handlePresetMoE) // avis MoE / chargement pour le preset édité
api("/api/preset/autoplace", handlePresetAutoPlace) // copie du preset en placement auto (--fit)
api("/api/preset/delete", handlePresetDelete)
// Presets externes : le chat part vers une API OpenAI-compatible distante au
// lieu du llama-server local (backend_external.go).
@@ -334,6 +339,11 @@ func newWebMux() *http.ServeMux {
api("/api/bench/status", handleBenchStatus) // progression, puis résultat
api("/api/bench/cancel", handleBenchCancel) // POST : annule
api("/api/bench/last", handleBenchLast)
api("/api/tune", handleTune) // POST : optimiseur sans perte, en arrière-plan (backend_tune_job.go)
api("/api/tune/status", handleTuneStatus) // progression, puis résultat
api("/api/tune/cancel", handleTuneCancel) // POST : annule
api("/api/tune/apply", handleTuneApply) // POST : écrit le résultat, sur clic seulement
api("/api/tune/last", handleTuneLast)
api("/api/perf/summary", handlePerfSummary)
api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
+3
View File
@@ -319,6 +319,9 @@ func handleVramReload(w http.ResponseWriter, r *http.Request) {
if !postOnly(w, r) {
return
}
if tuneDenyHTTP(w) {
return
}
if serviceIsActive() {
sendJSON(w, 200, map[string]any{"ok": true, "already": true})
return