Compare commits

...
Author SHA1 Message Date
Claude 30ce3a4e86 MTP : MODEL_DRAFT égal au modèle principal ignoré (poids chargés deux fois)
Choisir le modèle lui-même comme brouillon dans l'éditeur de preset
écrivait MODEL_DRAFT=<le modèle>, et Loki passait -md vers ce fichier :
common_speculative_init_result charge alors un second exemplaire complet
du modèle, au lieu de greffer le contexte MTP sur le modèle déjà en VRAM.
Swift 27B : « allocating 9628.34 MiB on device 0 » pour le brouillon,
moteur mort ; quand ça passait, couches renvoyées sur le CPU et 5 t/s au
lieu de 22 (prefill 290 t/s au lieu de 890).

probeSpec reconnaît ce cas (os.SameFile), specArgs l'ignore et le dit :
la tête MTP intégrée sert de brouillon sans -md. L'éditeur ne propose
plus le modèle principal dans la liste des brouillons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wfx8uW1un1WwWJ6xzAbkV
2026-10-08 09:13:56 +00:00
LogiFlow a463ccbd9f Merge branch 'claude/wizardly-lamport-jfgrq5' : arrêt du moteur sans course avec la relance, VRAM rendue
Arrêt du moteur : attendre tout l'arbre (VRAM rendue avant la relance)
2026-10-08 10:08:13 +02:00
Claude 393004cb06 Arrêt du moteur : attendre tout l'arbre, pas seulement le chef
En conteneur, userSvcStop n'attendait que le processus `loki serve`. Avec
Strata, le moteur qui tient la VRAM (cache d'experts, ~9 Go) est un autre
processus qui finit de s'arrêter après le serveur Python : la relance
suivante démarrait llama-server sur une carte encore pleine (« cudaMalloc
failed: out of memory » à l'encodeur de vision) et Strata mesurait la RAM
disponible avec l'ancien moteur encore chargé (mode mmap choisi à tort).

stopProcTree relève l'arbre (session, groupe et descendants) avant tout
signal, SIGTERM puis SIGKILL aux survivants, et ne revient qu'une fois
tout parti.

Diagnostic : une pile d'appels citant load_model ne passe plus pour une
nouvelle tentative (la cause mémoire était perdue), et le chat donne
l'échec de chargement au lieu de « il démarre, réessaie ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wfx8uW1un1WwWJ6xzAbkV
2026-10-08 07:18:53 +00:00
MichaelandClaude Opus 5.5 7d614324a0 Merge branch 'fix/tune-lock-race' : CI verte, verrou de l'optimiseur sans course
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:29:11 +02:00
MichaelandClaude Opus 5.5 853e59c085 Optimiseur : le ménage du verrou ne croise plus sa prise ni son rendu
TestTuneRecoverNeverReapsOwnLock échouait au hasard sous Linux (2 fois sur
5 déjà en v0.15.0, CI rouge). Le ménage (tuneReapStale) déplaçait un verrou
vivant, le relisait puis le remettait en place ; si release() passait entre
les deux, il ne trouvait plus le fichier et rendait l'inscription. Le verrou
remis en place devenait orphelin, et le ménage suivant le prenait pour une
optimisation interrompue : moteur relancé, preset « rétabli » à tort.

Un mutex de processus (tuneFileMu) sérialise désormais les opérations sur
le fichier — prise, réécriture, rendu, déplacement-relecture du ménage.
L'arrêt d'un essai orphelin et le retour du preset restent hors du mutex.
Vérifié sous Linux : 40 passages sans échec, suite complète verte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:29:08 +02:00
MichaelandClaude Opus 5.5 e7fb63b1dd Merge branch 'fix/strata-ram-mmap' : experts de Strata selon la RAM disponible, mode mmap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:26:54 +02:00
MichaelandClaude Opus 5.5 6aaf9360b4 Strata : experts placés selon la RAM disponible, mode « depuis le disque » (mmap)
Vécu : 62 Go de RAM dont 18 déjà pris par le reste du serveur. Le mode
« carte d'appoint hors RAM » avait été choisi sur la RAM TOTALE, et le
premier lancement, chargé d'écrire experts.bin, a monté le moteur à ~41 Go
de mémoire anonyme : le noyau l'a tué (OOM).

- Placement décidé AU LANCEMENT sur la RAM disponible (MemAvailable), plus
  sur la RAM totale ; le mode retenu est écrit dans le journal du moteur.
- Nouveau mode « disque » : --mmap-experts, les experts sont lus dans les
  fichiers du modèle par le cache système, rien n'est chargé au démarrage.
  Choisi d'office quand la RAM disponible ne suffit pas.
- Plus de premier lancement qui écrit experts.bin : les modes « hors RAM »
  et « experts.bin mappé » ne servent que si le fichier existe déjà.
- Réglage « Experts » dans la fenêtre de Strata : auto, en RAM, ou depuis le
  disque ; la configuration montre le mode du dernier lancement et la RAM
  disponible de la machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:26:50 +02:00
MichaelandClaude Opus 5.5 c2a2cc5748 Merge branch 'feat/ajean-lot-images' : recherche d'images, aperçu du navigateur, vignettes, journal d'installation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:04:54 +02:00
MichaelandClaude Opus 5.5 ac6d8ad144 Journal d'installation : la progression de Strata visible, plus de lignes en double
- Les barres de progression qui se redessinent avec \r (téléchargement du
  modèle par l'installeur de Strata) n'apparaissaient jamais : le journal ne
  découpait que sur \n et restait figé pendant tout le téléchargement. Une
  version toutes les 10 s est montrée ; \r\n reste une fin de ligne.
- Le suivi du job relançait une requête chaque seconde sans attendre la
  précédente : quand le serveur était occupé, deux réponses ajoutaient les
  mêmes lignes au journal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:54:09 +02:00
MichaelandClaude Opus 5.5 5016396998 Repris d'AJEAN : recherche d'images, aperçu du navigateur, vignettes, liens d'images
- web_images (amont v0.17.7) : recherche d'images DuckDuckGo, pendant de
  web_search ; les images du web s'affichent dans le fil avec la loupe, sans
  Referer, et disparaissent si le site les refuse. Schéma minimal pour tenir
  le budget du préambule.
- Aperçu en direct du navigateur piloté (cu_autoshot.go) : une capture après
  chaque action browser_* qui change la page, gardée en RAM, jamais envoyée au
  modèle, retirée du journal en fin de tour ; une carte dans le fil, en fondu.
- Vignettes calculées par le serveur (?thumb=, amont v0.17.9) : ~560 px au
  lieu de l'original (7 Ko au lieu de 550 Ko sur une capture), chargées deux
  par deux avec un nouvel essai ; la loupe ouvre l'original.
- Un lien vers une image ([graphique](graph.png)) l'affiche au lieu d'un
  bouton de téléchargement.
- Chrome du computer use tué avec Loki sous Linux (Pdeathsig), même en cas
  d'arrêt brutal : plus de navigateur orphelin dans le conteneur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:30:05 +02:00
MichaelandClaude Opus 5.5 502f23e833 staticcheck : decryptChatStores retirée (remplacée par decryptChatStoresSkipping)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:58:40 +02:00
MichaelandClaude Opus 5.5 634cfb61f3 Merge branch 'feat/moteur-strata' : moteur Strata, correctifs et presets repris d'AJEAN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:40:10 +02:00
MichaelandClaude Opus 5.5 ecff7de5a3 Presets : glisser-déposer, pastilles contexte et capacités, prompt système dans l'éditeur
Repris d'AJEAN et adapté à Loki :
- liste réordonnable par glisser-déposer (SortableJS 1.15.6, MIT), ordre
  gardé côté serveur (/api/presets/order) et suivi par le sélecteur de
  l'en-tête ; le clic de fin de glissement ne bascule pas de preset
- pastille de contexte (32K, 128K, 40K…) et pastille « capacités » : œil
  quand le preset lit les images (MMPROJ, API externe déclarée multimodale,
  Strata), ampoule + niveau quand il raisonne
- éditeur : prompt système DE CE preset (rangé en base, sysprompt:<id>),
  projecteur vision sur le CPU (--no-mmproj-offload, visible seulement avec
  un mmproj)
- les projecteurs mmproj ne sont plus proposés comme modèle (éditeur et
  sélecteur de l'en-tête)
- l'aide « ? » reste survolable sur un réglage grisé

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:36:59 +02:00
MichaelandClaude Opus 5.5 127013b32b Correctifs repris d'AJEAN : chiffrement, preset externe, heartbeat, commande du moteur
- Désactivation du chiffrement (amont cac0cda, 19918c6) : une valeur
  illisible ne bloque plus tout (quarantaine datée + copie du keyvault), et la
  reprise au démarrage déchiffre aussi les conversations au lieu de retirer la
  clé après les seules pages. Propre à Loki : les images de conversation
  (chatimg/) sont maintenant déchiffrées elles aussi — elles restaient
  chiffrées sans clé. Les .bak chiffrés devenus illisibles sont retirés.
- Preset externe (amont #95) : `loki serve` sort sans erreur (plus de boucle
  systemd « MODEL non défini »), le pré-vol l'accepte, le superviseur du
  conteneur ne le prend plus pour un plantage.
- Unité systemd du moteur : TimeoutStopSec=5 (bascule de preset bloquée 90 s
  en pleine génération).
- Heartbeat SSE (amont #105) : plus d'écriture après le retour du handler.
- Paramètres → Configuration : commande exacte du moteur copiable (clé API
  masquée, Strata compris) et nombre de couches du modèle à côté de NGL, lu
  dans l'en-tête GGUF (amont #108, #43) ; aussi dans l'éditeur de preset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:28:42 +02:00
MichaelandClaude Opus 5.5 4a9a5a1712 Moteur Strata : un second moteur, choisi par preset (ENGINE=strata)
Strata (github.com/Niko1221/Strata, MIT) fait tourner Qwen3.8 Flash Next
(MoE 125 B) sur une carte de joueur en répartissant les experts entre VRAM,
RAM et disque. Intégration reprise d'AJEAN (backend_moe.go, « AJEAN MoE
1.0 ») : paquet figé de la release moe-v1.0, vérifié par SHA-256.

- backend_strata.go : détection de la machine, quant conseillé, installation
  en tâche (même suivi que llama.cpp), preset ENGINE=strata, lancement du
  serveur de Strata par `loki serve`, réglages d'un modèle installé
- pré-vol, vision, liste des presets, journal de chargement : Strata reconnu
- proxy OpenAI et relais : Host local pour Strata (il refuse un Host inconnu)
- benchmark et optimiseur refusés sur Strata (protocole propre à llama-server)
- UI : ligne Strata dans Paramètres → Moteur (visible aussi avec le moteur de
  l'image), fenêtre d'installation et de réglages, moteur actif affiché
- image : python3 + python3-venv ; compose : memlock illimité

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:03:22 +02:00
MichaelandClaude Opus 5.5 5dd14c9de5 Version 0.15.0
Version consacrée à la vitesse sans dénaturer le modèle : les lots 1, 2 et 3
de performance. Ce qui est sans perte est actif d'office ; tout ce qui change
ce que voit le modèle, le placement ou les slots attend une clé.

- Version passée à 0.15.0 (run.go, versioninfo.json, ressources Windows
  régénérées par goversioninfo seul — icônes inchangées).
- Notes de release réécrites : actif d'office, opt-in et leurs clés, ordre de
  test conseillé, ce qui a été écarté (cache-reuse, context-shift, KV
  quantifié par défaut) et pourquoi.
- README : une section « Performance » rassemble toutes les clés (défaut,
  effet, prix ou zone grise), les outils sans clé, l'ordre de test et les
  pistes écartées ; le détail de chaque clé y est déplacé depuis
  « Fonctionnalités », qui n'y renvoie plus que par un lien.
- NOTICE inchangé : rien de ces lots ne reprend OpenFox ni AJEAN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:32:27 +02:00
MichaelandClaude Opus 5.5 5d37bef0ce Moteur : une bascule plus récente périme la vérification du rendu, plan et retour en POST
La vérification du rendu du gabarit après une mise à jour attend jusqu'à
vingt minutes que le nouveau moteur réponde. Un retour à la version
précédente, une autre version installée ou une seconde mise à jour pendant
cette attente la laissaient courir : elle comparait alors l'ancien moteur à
celui d'une autre bascule, ou écrasait le relevé de la suivante.

- Chaque bascule ouvre une génération ; une vérification périmée s'arrête
  sans rien ranger, et son relevé n'écrase plus le suivant. Retour et
  « utiliser » effacent le relevé, qui parlerait d'un moteur arrêté.
- /api/engine/plan (registre) et /api/engine/rollback (configuration et
  redémarrage) refusent tout autre verbe que POST — l'interface n'utilise
  que POST.
- Tests : vérification annulée en attente, relevé périmé ignoré, GET refusé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:24:21 +02:00
MichaelandClaude Opus 5.5 5b834d54b2 Optimiseur : la reprise toutes les 30 s ne prend plus un verrou vivant pour périmé
Depuis que le processus web refait la reprise des optimisations
interrompues toutes les 30 s, elle tourne pendant que ce même processus
prend et rend ses propres verrous (bouton « Optimiser… », application d'un
résultat). Trois fenêtres lui faisaient écarter un verrou bien vivant : le
fichier créé avant l'inscription du verrou comme nôtre, l'inscription
retirée avant le fichier (sous Windows, un fichier qu'un autre lit ne se
supprime pas), et le renommage d'un verrou neuf pris entre la lecture et le
ménage. En phase « application », le preset tout juste vérifié était alors
défait, et le moteur relancé.

- Inscription avant la création du fichier, retrait après lui ; un
  identifiant de prise (nonce) distingue deux verrous du même processus dans
  la même seconde.
- Au moment de le rendre, le verrou est réécrit neutre (rien à défaire,
  rien à relancer, aucun essai), puis retiré avec quelques essais.
- Le ménage relit ce qu'il vient de déplacer : un autre verrou, ou un verrou
  vivant, est remis en place par un lien (jamais par-dessus un verrou neuf).
- La relance d'après reprise passe par serviceAction : une optimisation
  neuve qui a pris le verrou entre-temps garde le moteur arrêté.
- Test : 60 prises et rendus en phase « application » sous une reprise en
  boucle, aucune relance ; échoue sur l'ancien code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:24:20 +02:00
MichaelandClaude Opus 5.5 c24472b91f Moteur : encart « version recommandée », retour à la version précédente, garde du rendu du raisonnement
Le lot 1 savait dire qu'un moteur officiel antérieur à b10864 évince trop tôt
les points de reprise des hybrides, mais laissait l'utilisateur chercher seul
quelle version prendre. Le panneau Moteur propose maintenant la version
recommandée, sur clic seulement, et garde un chemin de retour sans réseau.

La mise à jour elle-même n'est pas neutre pour le prompt : depuis b10763,
llama-server active preserve_reasoning par défaut, et un gabarit qui retirait
la réflexion des tours passés (Qwen3.6, clear_thinking) la rend alors — vide,
puisque Loki ne la renvoie pas sans REASONING_ECHO. Avant la bascule, Loki lit
le gabarit chargé et le verdict de la sonde ; si le rendu va changer, la
confirmation le dit et propose REASONING_PRESERVE=off (case cochée seulement
quand c'est établi : sur Qwen3.8, off changerait à son tour le rendu).
L'utilisateur choisit. Après la bascule, le rendu de conversations
synthétiques est comparé via /apply-template entre l'ancien et le nouveau
moteur, et le premier écart est signalé.

- encart sans réseau (build de confiance < b10864) : gains cités (points de
  reprise, MTP rapide b11009, MTP qwen4exp b11331), SPEC reste off
- POST /api/engine/plan, sur clic : dernier build publié, vérifié ≥ b10864 et
  tag figé existant ; sans réseau, une erreur et rien d'installé
- POST /api/engine/rollback + fichier engine/previous-bin : la version quittée
  (gardée par le ménage du lot 1) ; le retour la rend « précédente » à son tour
- REASONING_PRESERVE=off posée dans le preset actif, seulement si le nouveau
  moteur connaît --no-reasoning-preserve et que la clé est vide
- comparaison du rendu en tâche de fond, après chargement et changement de
  build_info (un ancien moteur survivant ne fausse pas le verdict)
- aucune clé nouvelle ; rien au démarrage ; aucune mise à jour sans clic

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:12:19 +02:00
MichaelandClaude Opus 5.5 911ffbfeaf Moteur : corrections de relecture — avec LOAD_GUARD=off, l'avis ne parle plus de refus
L'avis rendu quand le refus attend les cartes du moteur (VRAM NVIDIA seule)
annonçait « refus seulement si les cartes listées par le moteur le
confirment » même avec LOAD_GUARD=off, où aucun refus n'est possible. Il
retombe alors sur l'avis ordinaire.

- Test : VRAM NVIDIA seule avec LOAD_GUARD=off, et aucun avis qui évoque un
  refus quand la clé d'échappement est posée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:56:00 +02:00
MichaelandClaude Opus 5.5 5f7203a935 Interface : une seule lecture nvidia-smi à l'ouverture de l'éditeur de preset
À l'ouverture, l'éditeur demande en parallèle la taille du cache de prompts
(et celle du second slot) et les avis MoE. Chaque aperçu lançait son
nvidia-smi pour lire les mémoires totales — trois processus pour une réponse
identique, la mémoire d'une carte ne bougeant pas.

- La lecture brute des mémoires totales est partagée dix secondes ; le verrou
  tenu pendant la lecture fait attendre les demandes simultanées sur la
  même. La sélection des cartes (CUDA_VISIBLE_DEVICES) s'applique ensuite,
  comme avant.
- Un échec est gardé le même délai : une salve ne relance pas trois lectures
  vouées à échouer. « loki serve » ne lit qu'une fois : rien n'y change.
- Test : trois demandes simultanées, une lecture, chaque sélection juste ;
  échec non relancé dans la salve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:53:17 +02:00
MichaelandClaude Opus 5.5 4b659ac186 Raisonnement : NUDGE_IN_TOOL décide sur le verdict de la forme de la requête
La sonde de gabarit gardait le verdict de la DERNIÈRE forme sondée (outils,
chat_template_kwargs, reasoning_effort), quelle qu'elle soit. NUDGE_IN_TOOL
pouvait donc glisser le rappel de budget dans un résultat d'outil sur la foi
d'un « préfixe instable » conclu pour une autre forme — un autre jeu
d'outils, la réflexion coupée, un autre niveau — dont le rendu n'a rien à
voir.

- Les verdicts sont aussi rangés par forme (empreinte tplShape, au plus 16,
  la plus ancienne cède) ; tplCapsFor rend celui de la forme demandée.
- NUDGE_IN_TOOL calcule la forme de la requête courante (mêmes outils,
  kwargs et niveau que ceux envoyés) et ne place le rappel dans le résultat
  que si CETTE forme est « instable » pour ce modèle ; forme jamais sondée ou
  inconnue : message à part, comme sans la clé. Une relance outils coupés ou
  tool_choice « none » (forme que la sonde ne voit jamais) se replie aussi.
- L'affichage (/api/perf/summary) et REASONING_ECHO gardent le dernier
  verdict, comme avant. Sans la clé, rien ne change.
- Tests : verdict par forme dans la sonde, autre forme / autres kwargs /
  autre niveau refusés, bout à bout avec le verdict d'une autre forme
  (échouait sur l'ancien code).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:50:15 +02:00
MichaelandClaude Opus 5.5 ac40a2556a Optimiseur : « loki tune » refuse un moteur qui n'est pas celui de son LOKI_HOME
En ligne de commande, « loki tune » et le processus web avec des LOKI_HOME
différents (sudo qui retire un LOKI_HOME exporté, variable posée dans un seul
shell) ne se voient pas : le verrou tombait dans un dossier que l'interface ne
lit pas, l'arrêt du « vrai » moteur visait celui d'une autre configuration, et
l'essai chargeait à côté d'un moteur bien vivant — VRAM saturée, mesures
fausses, relance possible par l'interface en pleine mesure.

- Avant de commencer, la CLI regarde ce qui répond sur le port de son
  LOKI_HOME : un llama-server (/health 200, 503 ou 401) alors que ce dossier
  dit son moteur arrêté, un 401 sur /props avec sa clé d'API, ou un
  model_path qui n'est pas son MODEL (même fichier vérifié par os.SameFile)
  — refus, avec LOKI_HOME en clair et la marche à suivre (relancer avec le
  bon LOKI_HOME, ou le bouton « Optimiser… »).
- Tout ce qui ne conclut pas laisse passer : rien n'écoute, autre service
  (404), /props absent d'un moteur ancien, chemin illisible d'ici. Le
  processus web n'est pas concerné : il fait foi pour son moteur.
- Test : faux moteurs httptest pour chaque cas, refus et passages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:41:53 +02:00
MichaelandClaude Opus 5.5 a325e98dad Optimiseur : un « loki tune » tué par kill -9 ne laisse plus le moteur arrêté
Le verrou périmé n'était repris qu'au démarrage du processus web. Un
« loki tune » en ligne de commande tué sans ses defers (kill -9, terminal
perdu) laissait donc le vrai moteur arrêté — et peut-être l'essai en VRAM —
jusqu'au prochain redémarrage de l'interface.

- Le processus web refait la reprise toutes les 30 s, avec la même logique
  qu'au démarrage : essai orphelin arrêté (identité complète vérifiée),
  application interrompue défaite, moteur relancé seulement s'il tournait
  avant, ni sur un preset externe ni s'il est déjà reparti.
- Coût : un stat par tic tant qu'aucun verrou n'existe ; pendant une
  optimisation vivante, la lecture du verrou et de l'identité de son
  propriétaire. Une seule goroutine, endormie sur le tic.
- Test : relance au tic qui suit la mort du propriétaire, une fois ; rien
  sans verrou, rien pendant une optimisation vivante, rien pour un moteur qui
  ne tournait pas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:37:30 +02:00
MichaelandClaude Opus 5.5 f0fef91773 Moteur : LOAD_GUARD ne refuse plus sur la seule VRAM NVIDIA (Vulkan, cartes mixtes)
La borne basse des poids en RAM se calculait avec la VRAM de nvidia-smi. Un
moteur Vulkan sur une machine NVIDIA + AMD, sans --device, place aussi des
poids sur la carte AMD : la borne était fausse et le lancement refusé à tort.
Un moteur ROCm à côté d'une carte NVIDIA n'utilise pas du tout celle-ci.

- Le refus se décide désormais sur les cartes que le moteur liste lui-même
  (--list-devices, dans l'environnement du lancement, sélection GPU
  comprise), toutes additionnées : rien au-delà ne peut recevoir de poids, la
  borne est sûre. Une carte listée sans servir (iGPU) ne fait que rendre le
  refus plus rare.
- Lu seulement quand un refus se profile sur la VRAM NVIDIA : aucun lancement
  de plus dans le cas ordinaire. Les cartes de SPLIT_MODE sont reprises si
  elles sont déjà lues.
- Liste illisible, vide ou carte à 0 Mio (déjà pleine) : avertissement,
  jamais de refus. Même chose dans l'aperçu de l'éditeur, qui reprend la
  dernière liste complète du moteur (devices.json) sans le lancer.
- Le reste ne bouge pas : VRAM inconnue, --device, --rpc, mémoire unifiée.
- Tests : faux refus Vulkan évité, nvidia-smi seul n'est qu'un avis, lecture
  déclenchée seulement avec un refus en vue, somme des cartes (JSON relu).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:33:46 +02:00
MichaelandClaude Opus 5.5 a4db6d4da1 Moteur : un refus de LOAD_GUARD n'est plus relancé en boucle par systemd
Le refus d'un chargement résident trop gros pour la RAM faisait sortir
« loki serve » en erreur (code 1) ; l'unité loki-engine (Restart=on-failure,
RestartSec=3) le relançait toutes les trois secondes, sans fin, pour un échec
qui ne dépend que du preset et de la machine.

- Le refus sort avec un code dédié, 78 (EX_CONFIG) ; mustExit respecte le
  code porté par l'erreur, toutes les autres gardent 1.
- L'unité écrite par « loki install » porte RestartPreventExitStatus=78 ;
  les autres échecs restent relancés.
- Unité d'une version précédente (« loki update » ne réécrit pas les unités,
  il tourne sans droits root) : reconnue à coup sûr (parent systemd,
  INVOCATION_ID, unité et compléments lus sans la directive), le refus y sort
  sans erreur pour ne pas boucler, avec la marche à suivre au journal.
  Même chose sous launchd, qui relance toute sortie non nulle.
- Le moteur d'essai de l'optimiseur garde toujours le vrai code. Le
  conteneur ne relance jamais le moteur de lui-même : rien à y changer, la
  raison reste au journal et l'interface la montre (modelLoadError).
- Tests : directive reconnue (et seulement elle), code de sortie du refus,
  enveloppé ou non, unité générée (Linux).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:29:13 +02:00
MichaelandClaude Opus 5.5 d50c8a56c6 Interface : « n-grammes (mod) » brut rejoint SPEC=ngram, migration proposée pour les anciens presets
L'éditeur de preset offrait deux fois les n-grammes du contexte : l'option
« n-grammes du contexte » (SPEC=ngram, bornes explicites et garde-fous de
Loki) et l'ancienne « n-grammes (mod) », qui écrivait --spec-type ngram-mod
tel quel dans EXTRA_ARGS. Le sélecteur n'écrit plus que SPEC.

- Option brute ngram-mod retirée de la liste.
- Un preset qui porte encore --spec-type ngram-mod l'affiche sous son nom
  (« réglé à la main dans EXTRA_ARGS »), et une ligne propose de passer à
  SPEC=ngram en disant ce qu'elle retirerait (--spec-type, --spec-ngram-*,
  --spec-draft-* : laissés, ils feraient ignorer SPEC ou refuser les
  n-grammes au lancement).
- Rien n'est réécrit à l'ouverture ni sans clic, et le moteur lit toujours le
  drapeau brut : les presets existants tournent comme avant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:16:36 +02:00
MichaelandClaude Opus 5.5 432308c5b2 Compaction : une compaction en cours de tour ne fait plus disparaître le rappel des pages lues
Après une compaction en cours de tour, generate rangeait la vue publiée en
retirant TOUS les messages système de tête. Ceux que la vue d'envoi avait
posés (préambule, prompt du preset, contexte du projet ou bloc figé) n'ont
rien à faire dans l'historique ; mais le rappel « [MEMORY PAGES REMINDER] »,
posé par une compaction de début de tour, en fait partie — il partait avec
eux, et le modèle perdait la liste des pages à relire.

- historyFromView : un système de tête n'est gardé que s'il figurait en tête
  de l'historique d'où la vue est partie (la compaction garde la tête telle
  quelle) ; un système fusionné par InjectSkills devant le préambule est rendu
  tel qu'il était.
- Même correction pour la reprise du builder (mode Code) et le terminal, où
  le /sys de l'utilisateur disparaissait de la même façon.
- Test : la compaction réactive en plein tour garde le rappel, avec et sans
  PROJ_SNAPSHOT, et ne range aucun système injecté ; échoue sur l'ancien code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:11:59 +02:00
MichaelandClaude Opus 5.5 c888afb493 Contexte : avec PROJ_SNAPSHOT, la date et le dossier de travail passent dans le bloc figé
Le système portait deux valeurs qui bougent : la date du jour — à minuit,
toute discussion en cours recalculait son prompt entier — et le dossier de
travail, propre à chaque discussion, si bien que deux discussions ne
partageaient même pas leur système. Avec PROJ_SNAPSHOT, ces valeurs
rejoignent le bloc projet figé, en tête du premier message ; le système ne
garde que les consignes (« ton dossier de travail est dans ton contexte »).

- Bloc « Environment (from Loki) » : date (avec l'année, et l'année périmée
  que la consigne web nommait), dossier de la discussion, ou poste distant
  ciblé (nom, système, dossier, hors ligne).
- Contenu toujours vivant : relu à chaque tour ; un changement de jour part en
  une ligne de <context_update>, tout autre écart (poste hors ligne, autre
  cible) renvoie le message entier. L'avertissement « hors ligne » ne fige
  donc plus le système.
- Hôte, compte et dossier des scripts, les mêmes pour toutes les discussions,
  restent dans le système.
- Seul l'assemblage d'un tour de discussion avec la clé pose caps.envInCtx :
  tâches planifiées, sous-agents, vérification et terminal gardent leur
  système, date et dossier compris.
- Sans la clé : système identique à l'octet (gabarits relevés sur le code
  d'avant, test). Format d'instantané passé à 2 : les instantanés persistés
  sont repris neufs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:08:07 +02:00
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
MichaelandClaude Opus 5.5 09ee04037b Bench : corrections de relecture — niveau de raisonnement, préchauffage borné, verrou fiable
La relecture du bench en tâche de fond a trouvé quatre défauts qui le
faisaient échouer ou s'interrompre là où il n'aurait pas dû, et une
course entre la fin annoncée et le verrou rendu.

- Niveau de raisonnement refusé par le gabarit (Qwen3.8 et « high ») :
  le bench rejoue une fois avec le niveau accepté, comme runChatTools,
  et retient la traduction. Il échouait sur ce 500 dans un processus
  neuf. L'erreur HTTP garde le corps entier : les niveaux acceptés sont
  cités au-delà des 300 caractères affichés.
- Préchauffage borné au quart du contexte : -ub 4096 sur 8k de contexte
  envoyait un prompt plus grand que la fenêtre.
- Changer de discussion n'annule plus le bench, et ne lui reprend pas
  le verrou. La libération suit le drapeau benching au lieu de l'epoch,
  qu'une bascule de fil bumpe. Reset arrête toujours le bench et rend
  le verrou.
- Le verrou du chat est rendu avant que le statut ne dise « terminé » :
  un message envoyé aussitôt n'est plus refusé (et le test n'est plus
  instable).
- Indication « possible thrash » mesurée sur les tours seulement, pas
  sur le prefill à froid qui lit légitimement les pages du modèle.
- Interface : agrégats absents (omitempty) affichés à 0 au lieu de
  casser le rendu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:09:01 +02:00
MichaelandClaude Opus 5.5 cf83f5f629 Bench : une mesure honnête à profondeur réelle, en tâche de fond
L'ancien bench ne lisait pas le statut HTTP, inventait un partage 15/85
du temps quand les timings manquaient (et l'enregistrait comme mesuré),
tuilait un petit corpus qu'un brouillon recopiait, et ne disait rien du
prefill à 30k ni du chemin où le cache est repris. Synchrone, il était
coupé par les proxys (60-100 s) pendant que le moteur continuait, et un
simple GET suffisait à le lancer.

- Tâche de fond : POST /api/bench (202, 405 sur GET, 409 si occupé),
  /api/bench/status pour la progression, /api/bench/cancel ; chaque
  requête au moteur porte le contexte annulable.
- Verrou de génération tenu pendant toute la mesure : chat, tâches et
  compaction reçoivent un refus clair ; les clients /v1 un 503 avec
  Retry-After ; /slots occupé (client externe, CLI) refuse aussi.
- Mode rapide par défaut : préchauffage jeté + la ligne courte, sur le
  chat avec gabarit, raisonnement et échantillonnage du preset, seed fixe.
- Mode complet (bouton « bench complet », loki bench --full) : prefill à
  froid à D = min(CTX/2, 32k, ce qui laisse tenir les tours), 16k si des
  poids tournent sur CPU, puis 3 tours user → assistant → user de code
  jamais vu. Reprise du cache lue dans cache_n, jamais supposée ; note
  pour les hybrides (points de contrôle). Pas d'ignore_eos.
- Rien n'est enregistré sans réponses 200 et timings réels, ni pour un
  résultat partiel (budget de 6 min par requête, contexte plein).
- Empreinte enregistrée (configuration + CUDA_VISIBLE_DEVICES + protocole) ;
  les anciennes mesures retombent sur le nom du modèle. Types de cache KV
  et build du moteur affichés, KV quantifié signalé (zone grise).
- Linux : indication « possible thrash » (read_bytes, VmRSS), jamais
  enregistrée.
- Effacement du slot en fin de bench inchangé (engineSideJob, différé :
  il tourne aussi sur erreur et annulation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:52:54 +02:00
MichaelandClaude Opus 5.5 9f6bd725e6 Stockage : corrections de relecture — le remède Unraid décrit tel qu'il existe
« Exclusive access » n'est pas un réglage qu'on passe à Yes sur la page du
partage : c'est un état, que Unraid 6.12 accorde quand « Permit exclusive
shares » est activé et que le partage vit tout entier sur un pool, sans
stockage secondaire (/mnt/user/<partage> devient un lien vers le pool). La
consigne précédente envoyait chercher une option introuvable.
- conseil, compose et README : le vrai chemin (Global Share Settings,
  mover vers le pool puis Secondary = None, « Exclusive access : Yes »),
  et redémarrer le conteneur, Docker ne résolvant le lien qu'au démarrage ;
- un seul chemin par montage fuse.shfs : /data/models répétait /data ;
- l'encart de l'UI, permanent tant que la cause dure, devient masquable
  (localStorage, pour ce texte seulement — un autre conseil réapparaît).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:35:38 +02:00
MichaelandClaude Opus 5.5 056f307983 Stockage : Loki signale un /data ou /models servi par le FUSE d'Unraid
Sur Unraid, /mnt/user/… n'est pas un disque mais shfs, un démon FUSE :
chaque fsync de loki.db et chaque page d'un gros MoE mmappé relue depuis
le disque (modèle plus gros que la RAM) le traverse, à chaque token. Rien
n'est perdu ni altéré, mais le décodage ralentit — sans que rien ne le
dise.

Loki ne déplace rien : changer le chemin hôte d'une installation donnerait
un /data vide. Il le voit et le dit, sur un ton d'information :
- sys_storagefuse.go lit /proc/self/mountinfo (type lu après le séparateur
  « - », montage le plus long qui contient le chemin) pour LOKI_HOME et
  chaque dossier de modèles ; Linux en conteneur seulement, mis en cache
  une minute puisque /api/status est interrogé en boucle ;
- conseil en jaune au démarrage et champ « hint » de /api/status, affiché
  en encart discret, distinct du bandeau rouge de perte de données ;
- compose Unraid et README : « Exclusive access » recommandé (même chemin,
  FUSE court-circuité), /mnt/<pool> en option avancée avec migration
  manuelle et mise en garde sur un pool inexistant (RAM).

La détection de thrash du cache de pages est laissée de côté : elle exige
un signal majflt calibré par modèle sur le serveur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:31:59 +02:00
MichaelandClaude Opus 5.5 667e340e57 Persistance : corrections de relecture — un résultat d'outil ne forge plus de discussion
Relecture de l'écrivain différé (chat_persist.go) : l'ordre, la suppression
et les bascules tiennent ; trois écarts de comportement corrigés.

- saveToolResult et la télémétrie de generate lisaient la clé brute de la
  discussion active ; passés à convEnsureActive, ils pouvaient en CRÉER une
  (tâche de fond sur base neuve → discussion vide dans la liste). Nouveau
  convActiveID : cache chaud, sinon la clé en base, jamais de création.
- Un résultat d'outil confié après la suppression de sa discussion restait en
  mémoire pour toujours (l'écrivain l'écarte, la copie ne partait plus) : il
  n'est plus retenu.
- Vidage de l'écrivain avant « verrouiller la mémoire » (sinon l'envoi tout
  juste fait ne touchait le disque qu'au déverrouillage) et avant le restart
  systemd après MAJ (SIGTERM ne laisse rien finir).

Tests : résultat d'une discussion supprimée ni écrit ni retenu ; base neuve,
saveToolResult reste sous « nosession » sans créer de discussion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:27:54 +02:00
MichaelandClaude Opus 5.5 c3005f5b56 Persistance : le fsync de la discussion ne retarde plus le premier token
StartTurn persistait AVANT de lancer la génération : un marshal, puis deux
commits bbolt (contenu, puis index), soit deux fsync et plusieurs ouvertures
de base — 26-30 ms mesurés sur un NVMe Windows, bien plus sur un /mnt/user
d'Unraid ou un disque de parité, payés à chaque message avant que la requête
ne parte vers llama-server. Même facture au milieu d'un tour (vérification du
mode Code) et pour chaque résultat d'outil coupé (« voir plus »).

- chat_persist.go : un écrivain unique et ordonné. L'instantané reste pris
  sous c.mu (images → références, marshal, identifiant de la discussion) ;
  seule l'E/S part en différé. Le dernier instantané de chaque discussion
  l'emporte (numéro pris sous c.mu), les lots sont fusionnés en UNE
  transaction : contenu + index ensemble, résultats d'outils compris.
- Seuls StartTurn, la persistance en cours de tour et les résultats d'outils
  sont asynchrones. Fin de tour, reset, bascules, compactage manuel restent
  synchrones et attendent tout ce qui précède ; la suppression écarte puis
  attend les écritures qui la visent (plus de discussion ressuscitée).
- Discussion active gardée en RAM, créée sous verrou (plus de double
  identifiant sur base neuve) et changée sous c.mu avec le contenu : un
  instantané ne peut plus écrire l'ancien fil sous le nouvel identifiant.
- « Voir plus » servi depuis la mémoire tant que le résultat n'est pas écrit ;
  verrouillage vérifié avant de rendre un id.
- En plein tour, la base reçoit le journal sous sa forme de fin de tour
  (compactLog, version pure de compactLogLocked) ; la mémoire n'est pas touchée.
- Vidage de l'écrivain avant redémarrage, « Quitter », exec et (dé)chiffrement.

Le contexte vu par le modèle ne change pas : il lit c.Messages en mémoire, les
résultats complets d'outils et la compaction du journal ne servent qu'à l'UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:19:03 +02:00
MichaelandClaude Opus 5.5 9b2e974942 Moniteur : corrections de relecture — un nvidia-smi pendu ne fige plus les jauges
gpuStatsCached tient son verrou pendant la requête : c'est ce qui fait
partager une seule lecture aux onglets. Mais la requête n'était pas bornée,
et le code le dit ailleurs (nvidiaGPUCount) : un pilote coincé peut faire
pendre nvidia-smi. Avant le cache, chaque nouvelle requête relançait son
propre nvidia-smi et les jauges revenaient avec le pilote ; avec le verrou,
un seul processus pendu bloquait /api/vram pour tous les onglets jusqu'au
redémarrage de Loki.

- nvidiaSmiQuery : CommandContext borné à 10 s (large pour une première
  lecture lente sans mode persistant), WaitDelay d'une seconde. Au-delà,
  une erreur ordinaire, retentée au délai normal du cache.
- Le déchargement profite de la même borne : une lecture pendue devient une
  mesure ratée, que gpuSettle sait déjà sauter.
- Test : la vraie requête, binaire absent du PATH, rend bien
  exec.ErrNotFound — la seule erreur que le cache garde une minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:01:11 +02:00
MichaelandClaude Opus 5.5 4f34694400 Moniteur : un seul nvidia-smi pour tous les onglets, aucun pour un onglet caché
Chaque onglet ouvert (et chaque téléphone) interrogeait /api/vram toutes les
3 s, et chaque requête lançait son propre nvidia-smi — y compris depuis un
onglet en arrière-plan que personne ne regarde, pendant que llama-server
génère. Rien de tout cela ne touche au modèle : seules les jauges sont
concernées.

- gpuStatsCached : lecture partagée de 2 s, verrou tenu pendant la requête
  pour que des appels simultanés attendent la même lecture. nvidia-smi
  introuvable (Mac, CPU seul) mémorisé une minute ; une autre erreur se
  retente au délai normal.
- Seul handleVram passe par le cache. Le déchargement (lecture « avant » et
  gpuSettle) garde des lectures fraîches : deux valeurs égales servies par
  le cache feraient conclure gpuSettle à tort. Le cache est vidé après un
  déchargement.
- UI : statut, VRAM et RAM ne sont plus interrogés dans un onglet caché ;
  lecture immédiate au retour.
- Tests : cache (réutilisation, erreur, binaire absent, appels simultanés)
  et échantillonneur du déchargement qui contourne le cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:57:55 +02:00
MichaelandClaude Opus 5.5 3dff7e0e04 Flux : corrections de relecture — le « +N » d'une écriture ne recompte plus tout le corps
Chaque événement de frappe (un par ligne) appelait bodyLineCount sur le corps
ENTIER pour remplir body_lines : un reste de coût quadratique, léger mais
inutile, puisque argPreview tient déjà le compte des sauts de ligne.

- argPreview.lineCount : même règle que bodyLineCount (saut de ligne final
  sans ligne de plus), en temps constant.
- Le test de décodage par morceaux vérifie l'égalité avec bodyLineCount à
  chaque préfixe (fuzz compris).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:54:19 +02:00
MichaelandClaude Opus 5.5 f1f6cbe4ea Flux : l'écriture d'un gros fichier ne coûte plus un temps quadratique côté Go
Pendant qu'un modèle tapait un write de 120 Ko, chaque morceau faisait relire
et redécoder tout le JSON des arguments, puis republiait le corps ENTIER à
chaque ligne : 250 Mo de flux SSE, autant de tas vivant dans le journal, et
près de 8 s de CPU volées au décodage quand des experts tournent sur le
processeur. Même travers pour la détection des appels d'outils écrits en
texte, qui repassait toutes les regex sur toute la réponse à chaque jeton.
Rien de ce que voit le modèle ne change : les arguments exécutés restent
entiers, la passe complète de fin de tour aussi.

- argPreview : lecture incrémentale de l'argument affiché et du corps, même
  règle que previewArgDone (barre oblique coupée gardée en attente) ;
  arguments accumulés dans un strings.Builder, cur.Function.Arguments recalé
  sur lui à chaque morceau.
- Événement de frappe : seules les 40 dernières lignes (4 Kio au plus) du
  corps, avec body_lines (vrai « +N ») et body_tail ; l'UI affiche « … » et
  garde le repli sur le décompte local.
- textualToolCallFrom : ne relit que la fin, depuis un vrai début de ligne
  avant la fin du balayage précédent, en reculant sur les suites que les
  motifs embarqués peuvent traverser. Surcharge retry_patterns : balayage
  complet, comme avant.
- Direct SSE : les événements trouvés à un réveil partent en une écriture
  (lots de 128 Kio / 256 événements au plus), un sceau par événement en E2E.
- Tests : fuzz d'argPreview contre previewArgDone, équivalence fenêtre /
  balayage complet au morceau près, corps borné de bout en bout, ordre des
  lots ; bancs d'essai.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:48:49 +02:00
MichaelandClaude Opus 5.5 eaf5e313d9 Cache : corrections de relecture — la ligne MCP et les descriptions d'outils partagent un seul format
mcpPromptLine relisait le préfixe « [MCP: <serveur>] » des descriptions en le
réécrivant à la main : changer le format dans mcpTools aurait fait disparaître
la ligne en silence. Un nom de serveur contenant « ] » y était aussi coupé.

- mcpTagDescription pose le préfixe, mcpToolServer le relit : un seul endroit.
- mcpToolServer retient la coupure dont la forme assainie est celle du nom
  d'outil — « a] b » reste « a] b ».
- Les tests qui appellent EnabledTools isolent $LOKI_HOME : la configuration
  MCP de la machine de test (et ses connexions) ne fausse plus le cas « sans
  outil MCP » ni le budget du préambule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:34:36 +02:00
MichaelandClaude Opus 5.5 7dd319a058 Cache : la ligne MCP du préambule ne manque plus au premier tour après un démarrage
Le préambule système était bâti (InjectSkills → baseSystemPrompt) AVANT que
runChat n'appelle EnabledTools, donc avant que mcpTools n'ouvre les connexions.
mcpPromptLine lisait le pool encore vide : le premier tour après un démarrage
partait sans la ligne MCP, le second avec — tout le prompt à recalculer pour
une ligne. Elle comptait en plus les outils masqués par l'utilisateur, et
s'affichait pour le planner, qui ne reçoit aucun outil MCP.

- prepareTurn calcule les outils UNE fois par tour, puis le préambule à partir
  d'eux ; runChatTools les reprend tels quels (pas de second passage par
  mcpEnsureAll, qui retentait deux fois un serveur en panne).
- mcpPromptLine se déduit de cette tranche : seuls les outils réellement
  envoyés sont comptés, et rien n'est annoncé sans outil MCP.
- trackerList départage les égalités d'horodatage par nom puis slug : la liste
  part dans le contexte, un ordre tiré au sort changeait le prompt.
- Le commentaire de tasks_run.go ne prétend plus que le préfixe d'une tâche
  égale celui du chat (dossier de travail et taskCaps diffèrent).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:31:00 +02:00
MichaelandClaude Opus 5.5 9263df404d Cache : corrections de relecture — un rappel de loki ne laisse plus deux messages user d'affilée
Persister les rappels ouvrait trois façons de coller deux `user` dans
l'historique, celles-là même qu'appendNudge voulait éviter : sur un gabarit à
alternance stricte, chaque tour suivant aurait échoué.

- Fin de tour : un rappel resté sans réponse (stop, erreur) ou suivi d'un
  ajout en cours de réponse est retiré avant persistance (dropStrayNudges),
  comme avant sa persistance.
- Compaction : la queue ne commence plus sur un rappel ; la vraie demande
  réinjectée devant elle lui était collée. Elle recule d'un groupe d'outils.
- isLokiInjected ne se contente plus du préfixe « [system] » : un message de
  l'utilisateur qui commence ainsi n'est plus pris pour un rappel (sa demande
  était sautée par la compaction, le vérificateur et le titre).
- Relance tool_choice « none » : pas de repli après un stop, qui partait sur
  un contexte annulé et affichait une erreur.
- Tests : appel par le protocole et refus 4xx sous « none » (et rien
  d'exécuté), frontière de compaction, nettoyage de fin de tour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:25:39 +02:00
MichaelandClaude Opus 5.5 38b488515a Cache : les rappels de loki et la relance après un 500 ne recalculent plus la conversation
Le rappel de budget d'outils et la relance « pensé sans agir » partaient au
moteur sans entrer dans l'historique : au tour suivant, le fil divergeait
juste avant eux et toute la boucle d'outils qui suivait était recalculée.
Le modèle relisait en prime une histoire qu'il n'avait jamais vue. La
relance après un 500 (appel d'outil mal parsé) retirait les outils et
modifiait le message système : tout le prompt repartait de zéro, deux fois.

- Rappels persistés tels qu'envoyés (appendNudge), sauf derrière un autre
  message user : éphémères là, pour ne pas casser à chaque tour les gabarits
  à alternance stricte.
- Ils restent reconnaissables (isLokiInjected) : la compaction ne les
  réinjecte pas comme demande en cours et ne les prend pas pour la preuve
  qu'une demande a été servie ; la réduction forcée coupe d'abord aux vraies
  demandes ; le vérificateur, l'export, le titre et le résumeur les sautent
  ou les présentent comme note du système.
- Relance après un 500 sur le llama-server local : mêmes outils, même
  message 0, tool_choice « none » et la consigne au bout du dernier message,
  jamais persistée. Nouveau refus, appel tenté malgré tout ou réponse vide :
  repli une fois sur le chemin historique. Preset externe inchangé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:16:48 +02:00
MichaelandClaude Opus 5.5 72ba8e0fb6 Compaction : corrections de relecture — une balise </think> citée n'ampute plus le résumé
cleanSummary coupait toujours au DERNIER </think>. Un résumé de session
Code qui cite la balise (un parseur, un gabarit — sessions sur Loki même)
perdait donc tout ce qui la précédait, sans erreur ni trace : exactement la
perte que le commit précédent voulait supprimer.

- Le raisonnement ne se retire qu'en tête : un bloc <think>…</think> qui
  ouvre la réponse (plusieurs à la suite au besoin), ou un </think> sans
  <think> avant lui (gabarit qui ouvre le bloc dans le prompt).
- Un bloc cité en milieu de texte reste tel quel.
- La coupe par le filet (API qui ignore max_tokens) est tracée elle aussi.
- Tests : CTX fixé (le filet en dépend), réponse du faux moteur derrière un
  mutex, cas de balises citées et de blocs multiples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:04:03 +02:00
MichaelandClaude Opus 5.5 e14ead98c4 Compaction : le résumé n'est plus amputé de son état d'avancement
Le résumé de compaction était coupé net à 2200 caractères (≈ 550 tokens)
alors que max_tokens l'autorise jusqu'à ~1600 tokens. Le prompt du résumeur
place l'ÉTAT D'AVANCEMENT en dernier : c'était donc exactement ce dont
l'agent a besoin pour reprendre qui tombait, et les tokens décodés au-delà
étaient jetés.

- La borne passe à compactSummaryBudget()×6 caractères : un simple filet
  pour une API qui ignorerait max_tokens, qui borne déjà la sortie.
- finish_reason=length : le texte est gardé, marqué « […] » et tracé
  dans le journal ([compact] résumé coupé par max_tokens…).
- Un <think> ouvert en tête et jamais refermé, ou un contenu vide à côté
  d'un reasoning_content, est une erreur : l'appelant retombe sur le torse
  dégraissé au lieu d'installer du raisonnement brut comme mémoire.
- Tests : table de cleanSummary, et un résumé de 7000 caractères qui
  revient intact du moteur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:00:04 +02:00
MichaelandClaude Opus 5.5 26fc348506 Contexte : corrections de relecture — la garde de marge ne compacte plus en boucle, et le preset externe compte comme avant
La garde de marge de génération réclamait 1,2 × la plus longue génération
récente, sans plafond. Une étape de 40 k sur une fenêtre de 65 k (ou de 20 k
sur 32 k, sans budget de raisonnement) demandait plus de place que
l'historique fraîchement compacté n'en laisse : la garde repartait à chaque
étape, et chaque fois résumait le résumé précédent.

- Garde : jamais avant la moitié de la fenêtre. Au-delà, le rejeu sur
  « length » reste le filet.
- Preset externe : le complément « messages arrivés depuis la mesure » ne
  s'applique plus. Son CtxUsed garde le raisonnement, qui couvrait déjà ce
  complément ; l'ajouter le faisait compacter plus tôt qu'avant.
- Journal [ctx] : l'estimation d'avant compaction (début de tour, en tour)
  n'est plus comparée au compte d'une requête compactée, ce qui donnait de
  faux écarts.
- Tests : génération énorme juste après compaction, seuil de 50 % pour toutes
  les fenêtres, comptage externe contre local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:53:38 +02:00
MichaelandClaude Opus 5.5 d6023ef8a0 Contexte : le raisonnement jamais renvoyé ne compte plus, et la compaction ne part plus trop tôt
CtxUsed valait usage.prompt_tokens + généré, raisonnement compris. Or Loki
ne renvoie jamais ce raisonnement au modèle (Message n'a pas de champ pour
lui) : la requête suivante pesait 1 à 8 k jetons de moins que le compte, et
la compaction, qui perd de l'information, partait vers 44-47 k au lieu de
49 k sur une fenêtre de 65 k. Rien ne change dans les requêtes : seul le
compte qui décide de compacter.

- Comptage : seulement les morceaux reasoning_content séparés par le
  llama-server local, remis à zéro à chaque tentative. Le <think> découpé
  chez nous reste dans le message renvoyé pendant le tour, il n'est pas
  retiré ; l'API externe garde l'ancien calcul. Un morceau vaut au plus un
  jeton : on ne peut que sous-estimer, le sens sans danger.
- Début de tour : le message utilisateur (et la file) arrivé depuis la
  dernière mesure s'ajoute en estimation, comme en cours de tour.
- Vérificateur : sa trace isolée écrasait CtxUsed avec sa petite taille, et
  la fin de tour décidait sur ce chiffre-là. Plus maintenant ; la jauge de
  l'UI ne bouge pas non plus et suit le même calcul que le serveur.
- Marge de génération : le compte gonflé offrait une marge par accident.
  Elle est remplacée par une vraie garde — compacter si 1,2 × la plus
  longue génération récente ne tient plus dans la fenêtre. Plancher et
  marge seuls ne devancent jamais le seuil de 75 %.
- Fenêtre pleine en plein raisonnement (finish « length » au-delà du
  seuil) : compaction puis étape rejouée, au lieu du nudge « arrête de
  raisonner ».
- Journal [ctx] : estimation contre compte réel au-delà de 2 % d'écart,
  chaque « length » et chaque nudge, pour vérifier qu'ils n'augmentent pas.
- Le seuil reste à 75 % : la réserve fixe proposée (toolResultMax/3 + 6 k)
  laissait trop peu de place sans budget de raisonnement ni max_tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:45:55 +02:00
MichaelandClaude Opus 5.5 18a723a671 Télémétrie : corrections de relecture — compaction aussi tolérante qu'avant, pas d'alerte de cache sur une API externe, seuil qui suit EXTRA_ARGS
La relecture du relevé par complétion a trouvé quatre écarts, tous
d'affichage ou de robustesse ; rien ne touche aux requêtes ni au contexte.

- Compaction : la réponse lue d'un bloc passait de Decoder à Unmarshal,
  plus strict (un octet parasite après le JSON faisait échouer une
  compaction qui passait). Retour au Decoder, et perfWire fait de même.
- API externe : son cache suit ses propres règles, sans slot ni points de
  reprise à surveiller ici. Plus de ligne ambre « recalculés » pour elle ;
  le relevé reste dans l'anneau.
- Seuil d'alerte : même priorité qu'au lancement — -cms d'EXTRA_ARGS, puis
  LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT, puis CKPT_MIN_STEP ; -ub d'EXTRA_ARGS
  avant UBATCH.
- UI : « recalculés après sous-agent, client /v1 » au lieu des identifiants
  internes (subagent, foreign).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:34:57 +02:00
MichaelandClaude Opus 5.5 80b7793773 Télémétrie : chaque complétion dit enfin ce qu'elle a repris du cache, recalculé et accepté du brouillon
Jusqu'ici, impossible de savoir si une ligne volatile s'était glissée dans
le prompt, si une mise à jour du moteur avait cassé les points de reprise
ou si l'acceptation du MTP s'était effondrée : le chunk final de llama.cpp
porte ces chiffres (cache_n, draft_n, cached_tokens), on les jetait.

Lecture seule : aucun champ ajouté aux requêtes, et le comptage du
contexte (usage.prompt_tokens → CtxUsed → compaction) ne change pas.

- perf_log.go : décodage à part et tolérant (perfWire) — un compteur mal
  typé ne jette plus le chunk final ni ne fait échouer une compaction ;
  cached_tokens, strictement entier dans streamChunk, y déménage aussi.
- Inconnu n'est pas 0 : pointeurs, et l'UI garde « prefill N tok » quand
  le moteur ne dit rien de son cache.
- Nature (main, subagent, verify, task, compact, bench, foreign) portée
  par le contexte, pas par Caps ; TTFT au premier delta de tout type.
- Perte de cache = total précédent − cache_n, seulement quand les messages
  prolongent strictement ceux de la complétion précédente de la même
  discussion et de la même nature (empreintes cumulées) ; jamais sur un
  flux coupé. Ce qui s'est intercalé (sous-agent, client /v1, bench) est
  nommé.
- Anneau de 5000 entrées en mémoire, sans texte ; GET /api/perf/summary
  (derrière la clé) : taux de cache, recalcul par tour et par nature,
  médianes pp/tg par profondeur, acceptation du brouillon.
- Ligne [perf] sur stderr seulement avec LOKI_PERF_LOG.
- UI : « prefill X nouveaux / Y en cache », ambre au-delà d'un seuil
  relevé sur un modèle hybride (espacement des points de reprise).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:29:17 +02:00
MichaelandClaude Opus 5.5 3ff56e01db Moteur : corrections de relecture du MTP opt-in — le contrôle après chargement voit enfin le journal, et un second lancement ne prend plus le jeton du premier
La relecture de 3a6ad6f a trouvé quatre trous, aucun ne touche la sortie du
modèle ; tous font que le garde-fou se trompe de verdict.

- Couches en RAM : lastOffload repartait de la dernière ligne contenant
  « load_model » — or llama-server écrit « load_model: initializing… » APRÈS
  le chargement, et « loading model tensors » aussi pour le brouillon. Le
  contrôle ne trouvait jamais rien, ou ne regardait que le brouillon. Repère
  désormais la ligne « loading model '<chemin>' » du serveur, et la pire des
  lignes offloaded compte (modèle ou brouillon).
- Jeton de tentative : il était consommé (et l'échec inscrit) AVANT le test du
  port. Un second « loki serve » refusé pendant que le premier chargeait
  marquait la configuration comme ratée. Lecture d'abord (specAutoPeek), puis
  rangement une fois le port libre (specAutoSettle).
- Process web : il effaçait le jeton sans le relire sous verrou, donc parfois
  celui d'un lancement plus récent. specAttemptClear ne touche qu'au sien.
- MODEL_DRAFT vers une tête EAGLE-3 ou dFlash : Loki imposait draft-simple, que
  le moteur ne peut pas charger. La note renvoie maintenant à -md et
  --spec-type dans EXTRA_ARGS.
- Une tête MTP choisie comme MODEL_DRAFT compte comme modèle utilisé (« utilisé
  par », garde à la suppression).
- Éditeur : choisir un brouillon sur un ancien preset dont le MTP est en
  --spec-type dans EXTRA_ARGS fait passer le preset à SPEC=mtp. Sans ça, le
  brouillon était ignoré.
- Tests : un journal réel (initializing après chargement, brouillon chargé à
  part), un second lancement qui ne fait que lire, le jeton d'un autre
  lancement gardé, les têtes eagle3/dflash, et MODEL_DRAFT dans les références.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:16:41 +02:00
MichaelandClaude Opus 5.5 3a6ad6f23b Moteur : décodage spéculatif MTP en opt-in — tête détectée dans le fichier, garde-fous VRAM, et un essai raté ne se rejoue pas
Une tête MTP (Qwen3.6/3.8, intégrée ou publiée à part en mtp-*.gguf) accélère
le décodage sans rien changer à la sortie : chaque jeton émis est tiré par
l'échantillonneur du modèle cible, un jeton du brouillon n'est gardé que s'il
coïncide. Le prix est ailleurs — 1 à 2 Go de VRAM, un prefill parfois plus
lent, une fonction très récente du moteur — d'où l'opt-in : rien ne change
pour un preset existant.

- SPEC=off (défaut, clé absente comprise) / auto / mtp. La tête se reconnaît
  au TENSEUR blk.{N-1}.nextn.eh_proj (backend_gguf.go), comme llama.cpp : la
  clé nextn_predict_layers seule ne prouve rien.
- MODEL_DRAFT : résolu comme MMPROJ, mais introuvable ne bloque pas le
  lancement. Tête à part → -md + --spec-type draft-mtp explicite (llama.cpp ne
  devine que sur la première tranche) ; autre brouillon → draft-simple, sinon
  chargé en VRAM pour rien.
- auto seulement si --fit place tout (ni couches chiffrées, -ts, -ot, experts
  sur CPU, -sm row, --fit off, -dev), contexte chiffré (fit pourrait sinon le
  réduire), pas de vision, build officiel ≥ 11009, jamais qwen4exp. mtp impose,
  avec un avertissement.
- Aide du moteur lue à chaque fois : draft-mtp et --spec-draft-n-max requis ;
  --draft/--draft-max jamais émis. EXTRA_ARGS (--spec-type, -md, -hfd,
  --spec-default…) et LLAMA_ARG_SPEC_* gardent la main.
- Tirage greedy fixé (exact pour toute chaîne). probabilistic seulement avec
  SPEC=mtp, refusé avec mirostat ou adaptive-p. SPEC_N_MAX → --spec-draft-n-max.
- Jeton de tentative : « loki serve » le pose avant de lancer, le process web
  l'efface dès que le moteur répond (sans page ouverte). Resté au lancement
  suivant, même preset, moteur et build → auto coupé et dit. Couches poussées
  en RAM après chargement : même verdict. Les erreurs MTP/brouillon du journal
  ont leur message.
- Éditeur : « auto » et « MTP » écrivent SPEC, « non » écrit SPEC=off ; champ
  Brouillon ; la recherche HF propose la tête MTP du dépôt, décochée.
- Tests : table de specArgs (gardes, sauts, brouillons, tirage), place avant
  EXTRA_ARGS, cycle du jeton sur une vraie base, lecture du journal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:09:24 +02:00
MichaelandClaude Opus 5.5 7f49310b7f Moteur : corrections de relecture des points de reprise — un slot de plus compte, et un « --version » coincé ne bloque plus le lancement
La liste des points de reprise de llama-server est PAR SLOT : la garde de RAM
de l'espacement d'office ne comptait qu'une liste, et sous-estimait d'autant
le pire cas avec PARALLEL > 1. Et « loki serve » lit désormais le build du
moteur avant de le lancer (hybrides seulement) : sans délai, un binaire coincé
à l'initialisation de ses backends bloquait le lancement, là où binHelp, lui,
était déjà borné.

- ckptSlots : --parallel d'EXTRA_ARGS, sinon PARALLEL, sinon 1 ; « auto » ou
  illisible compté pour 4. Le pire cas devient points × slots × poids.
- engineBuildOf borné à 15 s (WaitDelay pour un descendant qui garde la
  sortie) : passé le délai, build inconnu, ni avis ni drapeau. Profite aussi
  au panneau Moteur.
- Tests : PARALLEL=2 garde l'espacement, PARALLEL=4 et --parallel 4 le
  retirent ; table de ckptSlots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:57:08 +02:00
MichaelandClaude Opus 5.5 dd27b81af0 Moteur : points de reprise des hybrides protégés sur les moteurs anciens, et la version précédente gardée après une mise à jour
Avant b10864 (PR #28302), llama-server effaçait un point de reprise trop
proche du précédent même quand la liste avait de la place : sur un hybride
(Qwen3.5/3.6, Qwen3-Next), une reprise retombait jusqu'à ~8 k jetons en
arrière, recalculés à chaque tour d'outil. Un point de reprise est une copie
exacte de l'état récurrent : rien de ce que voit le modèle ne change, seul le
temps de prefill et la RAM hôte. Aucune mise à jour du moteur ici.

- Build cru seulement pour un moteur officiel (image, téléchargé, précompilé)
  et au-dessus de 5000 : un compilé en clone superficiel (build 1) ou un fork
  n'a ni avis ni drapeau. --version gardé par binaire, taille et date.
- Avis « moteur trop ancien » dans le panneau Moteur et au lancement d'un
  hybride.
- Opt-in CTX_CHECKPOINTS (--ctx-checkpoints) et CKPT_MIN_STEP
  (--checkpoint-min-step, > 0 : 0 laisserait l'éviction FIFO jeter le point du
  début de la conversation). Gardes séparées : -ctxcp/--ctx-checkpoints/
  --swa-checkpoints et -cms/--checkpoint-min-step, plus LLAMA_ARG_*.
- D'office : --checkpoint-min-step 2048 seulement sur un hybride lu dans le
  GGUF, build officiel < 10864, aide qui connaît le drapeau, aucun réglage de
  l'utilisateur, points de reprise actifs, modèle sous 70 % de la RAM et 32
  points sous le quart de la RAM libre. Sinon l'avis seul ; sur un MoE mmappé
  trop gros, conseil CTX_CHECKPOINTS=8 à 16.
- REASONING_PRESERVE=on|off → --reasoning-preserve / --no-reasoning-preserve,
  seulement si la clé est posée et que l'aide le connaît. Rien par défaut :
  Loki ne renvoie pas la réflexion passée, et aucun choix global ne garde le
  rendu de tous les gabarits (Qwen3.6 contre Qwen3.8).
- Mise à jour du moteur : la version téléchargée qui tournait avant n'est plus
  supprimée ; le panneau propose d'y revenir sans réseau.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:52:31 +02:00
MichaelandClaude Opus 5.5 c72467ce4c Moteur : corrections de relecture de l'isolation des travaux annexes — un slot que le travail n'a jamais pris n'est plus effacé
Un sous-agent, une vérification, une tâche ou un bench qui échoue avant que le
moteur ne le serve (erreur avant l'envoi, refus 4xx, arrêt immédiat) laissait
le slot 0 tel quel : il portait encore la conversation, que l'effacement jetait
alors sans qu'elle soit dans le cache RAM — recalcul complet garanti, soit
exactement ce que l'isolation devait éviter.

- engineServed() : runChat (réponse 200 du moteur local) et le bench le
  notent ; un travail annexe n'efface que si le moteur a servi au moins une
  requête de Loki depuis son début (finishSideJob, testable sans moteur).
- Les prompts d'un travail annexe ne sont plus retenus comme « la conversation »
  par noteEnginePrompt : deux vérifications de suite comparaient sinon la
  conversation au prompt du vérificateur, et le conseil pouvait se tromper.
- CACHE_RAM et CACHE_ISOLATE figurent dans le modèle de configuration
  commenté (loki config).
- Éditeur : une valeur fixée par EXTRA_ARGS ou LLAMA_ARG_CACHE_RAM (-1, 0)
  ne s'affiche plus comme « défaut du moteur ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:44:33 +02:00
MichaelandClaude Opus 5.5 3565b594ea Moteur : cache de prompts dimensionné et travaux annexes isolés — la conversation n'est plus recalculée après un vérificateur ou un sous-agent
llama-server garde en RAM hôte une copie exacte des états de slot qu'il quitte
(KV, état récurrent, points de reprise, brouillon MTP) et la recharge octet pour
octet. Mais son défaut de 8 Gio ne tient pas une conversation de 30 à 65 k
jetons à côté de l'état d'un vérificateur, d'un sous-agent, d'une tâche ou d'un
bench : à la requête suivante il évince la conversation pour sauver l'annexe, et
tout est recalculé (30 à 80 s sur un 27B). Rien de ce que voit le modèle ne
change ici : seul le bruit de découpage des lots, comme le cache_prompt actuel.

- CACHE_RAM (Mio ; vide/auto, nombre passé tel quel, -1, 0) → --cache-ram,
  seulement si l'aide du moteur le connaît. Loki se tait devant -cram
  d'EXTRA_ARGS et LLAMA_ARG_CACHE_RAM. L'auto ne descend JAMAIS sous le défaut :
  il agrandit seulement un modèle tout-GPU (VRAM NVIDIA connue, aucun poids ni
  KV sur CPU, NGL complet), d'après l'état mesuré dans le GGUF (2,5 × KV à CTX
  + état récurrent et points de reprise des hybrides), plafonné à 30 % de la RAM
  (limite cgroup comprise) et à la moitié de la RAM libre.
- --slot-save-path LOKI_HOME/slots (0700, purgé au lancement) uniquement si le
  moteur est en boucle locale ou protégé par clé, et si le dossier existe. Les
  proxys /v1 et du relais refusent désormais toute action /slots (405).
- engineSideJob efface le slot 0 APRÈS le sous-agent, la passe de vérification,
  la tâche planifiée et le bench (pas après la compaction : rien à y gagner).
  Synchrone, borné à 2 s, et seulement si : moteur local, build ≥ 8660 lu dans
  /props, total_slots == 1, slot 0 au repos, aucune requête de Loki en vol
  (compteur tenu par le chat, les résumés, le bench et les proxys).
  CACHE_ISOLATE=off le coupe.
- usage.prompt_tokens_details.cached_tokens : si la conversation revient avec
  moins de la moitié en cache, conseil (une fois) de relever CACHE_RAM.
- Éditeur de preset : champ « Cache de prompts » à côté d'UBATCH, avec la
  valeur auto calculée par le serveur (/api/preset/cacheram).
- Lecteur GGUF : embedding_length, head_count et dimensions ssm.*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:37:55 +02:00
MichaelandClaude Opus 5.5 d028e81ce2 Modèles : corrections de relecture du lecteur GGUF — v2, première tranche sans tenseur et octets corrompus testés
La relecture n'a trouvé aucun défaut dans le lecteur lui-même : bornes,
versions, tranches, cache et vérification du début des données tiennent. Trois
cas réels restaient pourtant sans test.

- GGUF v2 : même disposition que v3, désormais vérifié avec v1 et gros-boutiste.
- gguf-split --no-tensor-first-split : la première tranche ne porte que les
  clés, aucun tenseur ; la tête MTP est trouvée dans la suivante.
- Un octet corrompu à chaque position de l'en-tête (v1 et v3, valeurs 0xFF,
  0x7F, 0x00) : le lecteur rend la main sans jamais tomber dans le filet du
  panic rattrapé, qui ne doit rester qu'un filet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:22:12 +02:00
MichaelandClaude Opus 5.5 0e841c26bd Modèles : lecteur des métadonnées GGUF
Plusieurs réglages du moteur dépendent de ce que le fichier contient vraiment
(tête MTP, têtes KV par couche, couches récurrentes d'un hybride) et Loki ne
le devinait qu'au nom du fichier. ggufMeta lit l'en-tête, les clés et la
table des tenseurs, jamais les poids. Aucun appelant pour l'instant : rien ne
change au lancement.

- Champs retenus : architecture, block_count, nextn_predict_layers,
  head_count_kv (valeur unique ou tableau par couche), key/value_length,
  expert_count, context_length, full_attention_interval, hybride (une clé
  <arch>.ssm.*).
- Tête MTP reconnue comme llama.cpp la reconnaît : le tenseur
  blk.{block_count-1}.nextn.eh_proj.weight, cherché dans toutes les
  tranches. La clé nextn seule ne prouve rien (tête publiée à part).
- Robuste : chaque longueur bornée par ce qui reste du fichier, compteurs et
  imbrication plafonnés, GGUF v1/v2/v3 et gros-boutiste, panic rattrapé,
  fichier coupé (en-tête ou début du dernier tenseur) refusé, tranche
  manquante refusée.
- Cache par chemin, taille et date de chaque tranche ; les erreurs ne sont
  pas gardées.
- Tests sur des GGUF synthétiques écrits par le test : hybride avec MTP, tête
  absente ou mal placée, tranches, v1 et gros-boutiste, toutes les
  coupures, fichiers aberrants, cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:17:23 +02:00
MichaelandClaude Opus 5.5 c4f051a0e9 Moteur : corrections de relecture des garde-fous de fidélité — les variables LLAMA_ARG_* et le glissement par défaut des anciens moteurs se voient aussi
Les garde-fous ne lisaient qu'EXTRA_ARGS et KV_TYPE. Or llama.cpp applique ses
variables LLAMA_ARG_* avant la ligne de commande : un conteneur lancé avec
LLAMA_ARG_CACHE_TYPE_K=q8_0 ou LLAMA_ARG_CACHE_REUSE=256 changeait les calculs
sans un mot. Et un llama-server d'avant --context-shift glisse le contexte PAR
DÉFAUT : il jetait des jetons alors que rien n'était écrit nulle part.

- Type de cache effectif : variables, puis KV_TYPE*, puis EXTRA_ARGS, la
  source la plus forte est nommée dans la note ; warnSlowKV suit.
- --context-shift et --cache-reuse lus aussi dans LLAMA_ARG_CONTEXT_SHIFT et
  LLAMA_ARG_CACHE_REUSE ; moteur ancien (aide sans --context-shift) : on dit
  qu'il glisse et que --no-context-shift l'en empêche. Aucun drapeau ajouté.
- Vision reconnue aussi par --mmproj d'EXTRA_ARGS et LLAMA_ARG_MMPROJ.
- Placement figé lu par tensorOverride : --n-cpu-moe 0 ne compte plus,
  --n-cpu-ffn et LLAMA_ARG_N_CPU_MOE si. Même règle dans l'interface.
- Test du paquet : les étiquettes json:"cache_prompt" des structures sont
  aussi refusées hors du benchmark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:11:31 +02:00
MichaelandClaude Opus 5.5 2bf388f7b7 Moteur : garde-fous de fidélité — un cache KV quantifié ou un cache approché se voit, Loki n'en pose jamais
Le cache KV en q8_0 d'un preset MoE vivait dans EXTRA_ARGS : ni l'avertissement
de lenteur ni la liste de l'interface ne le voyaient, qui affichait « f16 (max
qualité) » pendant que le moteur tournait en q8_0. Rien n'empêchait non plus un
--context-shift ou un --cache-reuse de modifier en douce ce que voit le modèle.
Aucun drapeau n'est ajouté ni retiré : on DIT ce que la ligne finale change.

- Type de cache effectif (effectiveKVTypes) : KV_TYPE*, puis -ctk/-ctv et
  --cache-type-k/v d'EXTRA_ARGS par-dessus, la dernière occurrence gagne comme
  dans llama-server. warnSlowKV le reçoit désormais.
- Note au lancement quand ce cache n'est pas f16 : q8_0 modifie légèrement les
  sorties, q4_0 perte mesurable, bf16 numérique différente. Avec -ot ou
  --n-cpu-moe, --fit ne tourne pas : repasser en f16 demandera sans doute de
  relever --n-cpu-moe. Pas d'estimation de VRAM sans les métadonnées du GGUF.
- Avertissement pour --context-shift (jamais --no-context-shift) et
  --cache-reuse N>0 ; --swa-full, sans perte, n'en déclenche aucun. Vision
  chargée : llama.cpp les ignore, on le précise.
- Interface : options du cache KV étiquetées, sous-titre qui montre le type
  effectif (« défini par EXTRA_ARGS ») et les drapeaux de cache approché.
- Tests : notes et type effectif en table, aucun -ctk/-ctv sans KV_TYPE, corps
  réels du chat et de la compaction sans cache_prompt:false ni n_cache_reuse,
  et ces clés réservées au benchmark dans tout le paquet (arbre syntaxique).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:04:08 +02:00
MichaelandClaude Opus 5.5 a42c184ed2 Moteur : corrections de relecture de FIT_TARGET — -sm tensor coupe aussi --fit, -ngl -1 ne le coupe pas
common/fit.cpp abandonne sur LLAMA_SPLIT_MODE_TENSOR comme sur ROW
(« not implemented, abort ») : avec -sm tensor, la marge partait quand même
et la note annonçait un --fit-target qui ne servait à rien — de quoi mesurer
deux fois le même placement. À l'inverse, -ngl -1 est la valeur par défaut
du moteur (celle qu'écrit « auto ») : fit tourne, et la clé était refusée à
tort.

- fitBlocker : row ou tensor, en drapeau ou en LLAMA_ARG_SPLIT_MODE ;
  -1 traité comme auto.
- tests : -sm tensor, LLAMA_ARG_SPLIT_MODE=tensor, -sm layer, -ngl -1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:58:23 +02:00
MichaelandClaude Opus 5.5 9aa426a389 Moteur : trois réglages d'expert sur demande — marge VRAM par carte (FIT_TARGET), seuil d'offload des experts, branches Q/K/V parallèles
Sur deux cartes inégales (5060 Ti + 3060) ou un MoE aux experts sur CPU, il
reste quelques leviers de placement que llama.cpp expose mais que Loki ne
savait pas poser. Aucun ne touche au modèle (poids, contexte, cache,
échantillonnage) ; tous restent éteints tant que le preset ne les demande pas,
et le gain se décide à la mesure.

- FIT_TARGET=1024,3072 → --fit-target : marge libre par carte pour --fit.
  Validée (entiers, 8 valeurs au plus, jamais transmise brute : stoull ferait
  mourir le moteur en boucle), remontée à 1024 Mio au minimum. Seulement si
  l'aide la liste, sans -fitt ni LLAMA_ARG_FIT_TARGET déjà posés, avec un
  contexte chiffré (CTX=0 laisserait fit réduire le contexte) et si --fit
  tournera vraiment : NGL chiffré, --tensor-split, -ot, --n-cpu-moe, -cmoe,
  --fit off ou -sm row le coupent, et on le dit au lieu de ne rien faire.
- OP_OFFLOAD_MIN_BATCH=N → GGML_OP_OFFLOAD_MIN_BATCH : les lots de moins de N
  jetons restent sur CPU pour les poids en RAM. Utile pour des brouillons
  ngram de 32 jetons ou plus ; MTP vérifie déjà sous 32.
- CUDA_GRAPH_OPT=on → GGML_CUDA_GRAPH_OPT=1 : expérimental, sortie identique en
  amont, gain de 0 à quelques % ; refusé si -sm row/tensor ou un -ot sur
  l'attention pouvait séparer Q, K et V d'une couche. Le lancement rappelle
  « off puis redémarrer » en cas de GGML_ASSERT.
- une variable déjà dans l'environnement n'est jamais touchée ; elles ne vont
  qu'à llama-server via « loki serve ».
- tensorOverride, extrait de pipelineBlocker, sert aussi à la garde de --fit.
- --backend-sampling écarté : pas identique au bit près, et inactif sur les
  requêtes à outils (grammaire) qui font l'essentiel du trafic.
- clés documentées dans le squelette de « loki edit » ; tests de table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:53:44 +02:00
MichaelandClaude Opus 5.5 2de20e0ab2 Moteur : corrections de relecture des files de lancement CUDA — les variables LLAMA_ARG_* lues comme llama.cpp les lit
La garde « pipeline possible » ne suivait pas tout à fait la façon dont
llama.cpp (common/arg.cpp) lit ses réglages. Faux positifs : 4x posé sans
pipeline possible, donc exposé au seul cas de panne connu sans aucun gain.
Faux négatifs : gain refusé à tort.

- surcharges de tenseurs cumulées : variable et drapeaux s'additionnent.
  Un --n-cpu-moe 0 n'annule donc ni un LLAMA_ARG_N_CPU_MOE=30 ni un
  --n-cpu-moe 20 placé avant lui.
- --n-cpu-ffn/-ncffn (FFN dense sur CPU) et LLAMA_ARG_OVERRIDE_TENSOR
  comptent comme des surcharges.
- cache KV : le dernier -kvo/-nkvo l'emporte. Sinon, la seule présence de
  LLAMA_ARG_NO_KV_OFFLOAD (même à 0) vaut « non », puis LLAMA_ARG_KV_OFFLOAD
  est lu comme un booléen.
- LLAMA_ARG_CPU_MOE n'est actif que pour on/enabled/true/1.
- NGL absent : Loki passe -ngl lui-même, donc LLAMA_ARG_N_GPU_LAYERS ne
  compte plus. NGL=Auto se lit sans tenir compte de la casse, comme nglArgs.
- buildServeArgs : le commentaire des threads retrouve son code.
- 12 cas de table en plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:47:38 +02:00
MichaelandClaude Opus 5.5 f7f1a30d08 Moteur : files de lancement CUDA élargies (4x) sur deux GPU quand le pipeline entre cartes est possible
Sur plusieurs cartes, llama.cpp fait travailler les GPU en pipeline quand le
modèle y tient en entier ; encore faut-il que le CPU empile assez de
lancements CUDA d'avance. CUDA_SCALE_LAUNCH_QUEUES=4x agrandit cette file du
pilote : mêmes noyaux, même ordre, sortie identique — seul le prompt peut
gagner (+10-25 % en amont sur un 70B, non mesuré ici), le décodage ne bouge pas.
llama.cpp l'a retiré de ses défauts après des blocages sur Jetson : Loki ne le
pose donc que là où le pipeline peut réellement exister.

- launchQueuesEnv (pure) : 4x seulement si ≥ 2 GPU CUDA servis (--device,
  sinon CUDA_VISIBLE_DEVICES, sinon nvidia-smi borné à 3 s) et aucun bloqueur :
  -ot, --cpu-moe, --n-cpu-moe ≠ 0, -nkvo, -sm autre que layer, NGL chiffré
  hors 999 — drapeaux d'EXTRA_ARGS ou LLAMA_ARG_* équivalents
- une variable déjà dans l'environnement n'est jamais touchée ;
  CUDA_LAUNCH_QUEUES=off la coupe, 0.25x/0.5x/2x/4x l'imposent, toute autre
  valeur est ignorée en le disant
- la valeur posée s'affiche sur la ligne « [loki serve] » ; la ligne de
  commande ne change pas, BATCH/UBATCH non plus
- CUDA_LAUNCH_QUEUES survit aux bascules de preset (réglage machine qu'un
  preset peut imposer)
- éditeur : « Experts MoE sur CPU » rappelle que ça coupe le pipeline sur 2 GPU

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:43:04 +02:00
MichaelandClaude Opus 5.5 0a02604968 Moteur : corrections de relecture des threads CPU — un -tb absent n'efface plus les réglages batch d'EXTRA_ARGS
Sans -tb, llama.cpp ne recopie pas seulement le nombre de -t : il remplace
TOUT le réglage CPU du batch par celui de -t (postprocess_cpu_params,
« cpuparams = *role_model »). Un -Cb, -Crb, --cpu-strict-batch, --prio-batch
ou --poll-batch écrit dans EXTRA_ARGS était donc effacé sans un mot depuis que
Loki ne pose plus « -tb 0 » — un réglage utilisateur cassé.

- Dans ce cas seulement, Loki pose -tb : la valeur de -t quand elle est connue
  (THREADS, sonde du conteneur ou -t / --threads d'EXTRA_ARGS), sinon 0,
  l'ancien comportement, et le dit sur stderr.
- Un -tb déjà dans EXTRA_ARGS reste maître, comme avant.
- flagValue lit la dernière occurrence d'un drapeau (« -t 6 » ou « -t=6 »).
- Tests : quatre lignes de construction d'arguments et une table flagValue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:37:59 +02:00
MichaelandClaude Opus 5.5 c6c5dc2b7e Moteur : « auto » des threads CPU = les cœurs physiques, plus tous les threads logiques
Loki passait toujours « -t <THREADS|0> -tb <THREADS_BATCH|0> ». Pour
llama.cpp, 0 veut dire hardware_concurrency() : tous les threads logiques,
frères SMT compris. Avec l'attente active par défaut (--poll 50), deux
threads sur un même cœur se gênent, et c'est le décodage des experts MoE sur
CPU qui paie. Le vrai auto du moteur, c'est l'absence du drapeau : il prend
alors ses cœurs physiques (cœurs P seulement sur Intel hybride sous Linux).

- THREADS vide ou 0 : plus de -t ; THREADS_BATCH vide ou 0 : plus de -tb,
  le moteur recopie -t (prefill ET vérification spéculative MTP).
- Valeur illisible ou négative : ignorée et dite sur stderr, au lieu de
  faire boucler le moteur sur son analyse d'arguments.
- -t / -tb déjà dans EXTRA_ARGS : Loki ne double plus le drapeau.
- Linux, conteneur à l'étroit (cpuset restreint, quota CFS) : le moteur se
  rabattrait sur tous les cœurs de l'hôte. Une sonde compte les cœurs permis
  comme llama.cpp (thread_siblings, cœurs E écartés), plafonne au quota et
  ne pose -t que s'il est plus petit ; lecture ratée = rien. Muette si
  LLAMA_ARG_THREADS est posé ou si EXTRA_ARGS fixe l'affinité (-C, -Cr…).
- Libellés de l'interface et du gabarit de config corrigés.

Le calcul du modèle ne change pas. Gain non mesuré : comparer tg du preset
MoE à physiques, physiques-1 et logiques avant de conclure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:32:14 +02:00
MichaelandClaude Opus 5.5 03eb56aee3 Moteur : corrections de relecture de la construction des arguments
buildServeArgs se disait pure, mais la traduction des drapeaux de chargement
qu'elle appelle écrivait encore sur stderr quand un --load-mode dio tombait sur
un moteur ancien. La note est désormais rendue comme les autres et affichée
par cmdServe, au même rang qu'avant (avant la note NGL).

- normalizeLoadFlags / downgradeLoadMode rendent la note au lieu de l'écrire.
- Table de tests : cas dio sur moteur ancien (drapeau retiré, une note), et
  TestNormalizeLoadFlags vérifie que seul ce cas produit une note.
- Aucun changement de ligne de commande.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:27:04 +02:00
MichaelandClaude Opus 5.5 76f5697487 Moteur : construction des arguments de llama-server isolée et testée
cmdServe mêlait deux métiers : sonder le monde (chemins du modèle et du
projecteur, aide du moteur, clé d'API, port) et composer la ligne de
commande. Tout réglage de lancement à venir devait donc se vérifier en
relançant un moteur. La composition passe dans buildServeArgs, pure, qui
reçoit ce que cmdServe a sondé et rend arguments, variables d'environnement
et notes ; cmdServe garde seul les effets (Setenv, chdir, port, exec).

- serveSysInfo porte l'aide du moteur, les chemins résolus et la clé.
- Les tests de capacité lisent le texte de l'aide (helpSupports*), plus le
  binaire : une aide fabriquée suffit à les exercer.
- La sélection GPU reste posée avant la lecture de l'aide, comme avant.
- Aucun changement de comportement : même ordre, mêmes valeurs. Une table
  de tests fige la ligne pour les presets courants (dense, NGL forcé,
  MoE avec -ot/--n-cpu-moe/-ctk, raisonnement on/off, vision, clé d'API,
  drapeaux de chargement, CUDA_VISIBLE_DEVICES).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:24:18 +02:00
MichaelandClaude Opus 5.5 aebecd75bd Accès refusé : l'interface dit pourquoi et comment le lever
Depuis la protection anti-site-tiers (0.14.0), un accès par nom de domaine
derrière un reverse proxy, sans clé de pilotage, reçoit un 403 sur toute
l'API. L'interface restait vide : chaque appel échouait en silence dans la
console.

- Au premier 403, un bandeau fixe affiche la raison donnée par le serveur
  et le correctif : LOKI_TRUSTED_HOSTS=<le nom affiché> dans les variables
  du conteneur, ou une clé de pilotage.
- docker-compose.yml et docker-compose.unraid.yml exposent la variable
  LOKI_TRUSTED_HOSTS, commentée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:11:19 +02:00
MichaelandClaude Opus 5.5 cf8d1cfaaa Version 0.14.0
Rattrapage d'AJEAN jusqu'à la 0.17.6 (contexte, reprise réseau, presets
externes, tâches ponctuelles, file d'attente) et reprises d'OpenFox pour le
mode Code (vérification quand le builder a fini, pré-vol des écritures,
relance des appels textuels, alias d'outils, consignes du dépôt, terminal).
Les 19 commits locaux de la synchro 0.13.6 → 0.16.3, restés hors de main,
y sont rejoués.

Bump des trois porteurs de version (constante Go, versioninfo.json, .syso
Windows régénérés), notes de release réécrites, NOTICE à jour des idées
reprises d'OpenFox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:41:37 +02:00
MichaelandClaude Opus 5.5 b955181bb9 Mode Code : une écriture interdite est refusée dès son chemin, pas après le fichier
Repris du pré-vol d'OpenFox (tool-preflight, 2.0.154).

Un write sur un fichier existant jamais lu était refusé par le tracker…
une fois tout le fichier généré. builder.md demande des fichiers écrits en
entier : un refus pouvait coûter des minutes de décodage pour rien.

- Les schémas write, edit, mem_add et mem_edit annoncent le chemin AVANT
  le contenu. Une map Go sort ses clés par ordre alphabétique : le modèle,
  qui suit l'ordre du schéma, déroulait tout le contenu avant le chemin.
  L'interface nomme aussi le fichier dès le début de la frappe.
- Dès que le chemin d'un write/edit est complet dans le flux, les gardes du
  mode Code (fichier lu, chemin permis) sont vérifiées. Refus : la
  génération est coupée, l'appel réduit à son chemin entre dans
  l'historique avec le refus pour résultat, et le modèle repart (lire le
  fichier d'abord). Rien n'est écrit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:40:38 +02:00
MichaelandClaude Opus 5.5 15495431fc Mode Code : le compactage garde les fichiers touchés, les erreurs et les critères
Repris du COMPACTION_PROMPT d'OpenFox.

Après un compactage, le builder réécrivait des fichiers déjà faits et
retombait dans des erreurs déjà résolues : le résumé, pensé pour le chat,
ne gardait ni l'un ni l'autre. Et les critères, qui ne vivent pas dans le
fil (seuls les appels à l'outil criteria y passent), disparaissaient avec
le torse résumé.

- En mode Code, le résumé garde aussi chaque fichier créé ou modifié, les
  erreurs rencontrées et leur résolution, et les commandes de build/test.
- L'état des critères est rendu au builder dans le message de résumé — un
  message de toute façon neuf, donc sans cache invalidé en plus.

(Au passage : gofmt sur code_git.go, mal aligné au commit précédent.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:38:07 +02:00
MichaelandClaude Opus 5.5 d5632e3595 Mode Code : les consignes du dépôt (AGENTS.md, CLAUDE.md) font partie du contexte
Repris de context/instructions.ts d'OpenFox.

Un dépôt qui documente ses commandes de build et de test, ses conventions
et ses pièges le fait dans AGENTS.md ou CLAUDE.md. Le modèle les
redécouvrait à coups de read et de bash — quand il les redécouvrait.

En mode Code, le premier de ces fichiers trouvé (dépôt cloné détecté, puis
racine de la discussion) part avec le contexte du projet, tronqué à 4 000
caractères. Comme le reste du contexte projet, il est déplacé dans le
premier message utilisateur : le préfixe système, et le cache de prompt,
restent intacts.

Les tours de correction et de relance du builder reçoivent désormais le
même contexte projet que le tour de build : sans lui, le début du premier
message utilisateur changeait et tout le prompt était recalculé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:36:51 +02:00
MichaelandClaude Opus 5.5 dfafca6fb2 Mode Code : git sur le dépôt cloné, budget de build, sous-agents moins rigides
Trois trous relevés en comparant avec OpenFox.

- git_status / git_diff tournaient à la racine de la discussion, qui
  n'est souvent pas un dépôt : git_clone range le projet dans un
  sous-dossier, et le vérificateur — dont la consigne commence par
  git_diff — tombait sur « not a git repository ». Ils visent maintenant
  le sous-dossier demandé (dir), la racine si c'est un dépôt, sinon
  l'unique dépôt présent ; plusieurs : on demande de choisir.
- Budget d'outils triplé en mode Code : lire, éditer, compiler, relancer
  les tests dépasse vite 24 appels sans tourner en rond, et le troisième
  rappel (« n'appelle plus d'outil ») coupait le build au milieu.
- Sous-agents : température 0.6 au lieu de 0 (en glouton, Qwen avec
  réflexion boucle vite ; le TEMP du preset l'emporte toujours), et seul
  le texte écrit après le dernier outil revient au builder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:36:01 +02:00
MichaelandClaude Opus 5.5 2e0356cd6c Terminal : le vrai code de sortie, la sortie gardée au délai, sans ANSI
Repris de shell.ts / shell-tail.ts et diagnostics.ts d'OpenFox (2.0.160).

- « cmd | tail -N » : le code de sortie devenait celui de tail (0) et un
  build en échec passait pour réussi. Le tail est retiré de la commande et
  Loki garde lui-même les N dernières lignes : même sortie, vrai code.
- Délai dépassé : la sortie déjà produite est rendue (où en était le build,
  quel test bloquait) au lieu d'un « [timeout] » sec, avec le conseil de
  passer par bash_bg pour un serveur.
- Couleurs et séquences ANSI retirées : du bruit en tokens.
- La durée suit le code de sortie (« exit: 0 · 1.2s »).
- Mode Code : « cmd & » refusé, avec renvoi vers bash_bg — le process
  orphelin n'avait ni sortie lisible ni moyen d'être arrêté.
- Diagnostics LSP : erreurs d'abord, avec le compte total ; au-delà de la
  limite, des indices de style passaient devant l'erreur de compilation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:34:42 +02:00
MichaelandClaude Opus 5.5 a745d2399e Agent : read_file, str_replace, old_string… traduits vers les vrais outils
Généralisation du transformSubAgentAliases d'OpenFox. Les petits modèles
ont appris d'autres agents : read_file, str_replace, run_command, ou path /
old_string au lieu de file / old. L'appel tombait sur « outil inconnu »
ou sur un argument manquant, et le tour se perdait en allers-retours.

Le nom et les arguments sont traduits vers l'outil réel quand la cible est
disponible dans ce tour, avant que l'appel soit rangé dans l'historique
(le modèle y relit l'appel tel qu'exécuté). Un sous-agent appelé comme un
outil (« explorer », « code_reviewer ») devient subagent{role, task}. Un
appel déjà correct, ou dont la cible n'est pas offerte, reste intact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:32:29 +02:00
MichaelandClaude Opus 5.5 ad87f2fc9f Agent : un appel d'outil écrit en texte coupe le flux et la relance le cite
Repris d'OpenFox (stream-pure, agent-loop, 2.0.15x).

- Repéré PENDANT le flux (hors réflexion en ligne) : la génération est
  coupée tout de suite au lieu de laisser le modèle dérouler un faux appel
  — parfois un fichier entier — qui ne serait jamais exécuté.
- La consigne corrective cite l'extrait fautif : le modèle voit ce qu'il a
  mal écrit.
- Jusqu'à 3 relances consécutives au lieu d'une par tour ; le compteur
  repart à zéro après chaque appel émis par le protocole. Un long tour
  d'agent pouvait rater la syntaxe deux fois et finir sur un pseudo-appel.
- Nouveaux motifs : le format XML de Qwen3-Coder (<function=…>,
  <parameter=…>) quand le gabarit du moteur ne l'a pas converti.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:31:33 +02:00
MichaelandClaude Opus 5.5 7b754c0cb6 Mode Code : vérifier quand le builder a fini, jamais pendant une question
Repris du buildAgentNudge d'OpenFox (2.0.133).

La passe de vérification partait à chaque fin de tour de build, y compris
quand Qwen s'arrêtait sur « ensuite je vais… » ou venait de poser une
question avec ask. Chaque passe inutile coûte un prefill complet, évince
le cache KV du fil, et produit des « failed » qui ne disent que « pas
encore fait » — en courant par-dessus la question restée sans réponse.

- Nouveau statut « completed » : le builder marque un critère fait une
  fois vérifié par lui-même. Seule la vérification marque passed/failed.
- Fin de tour avec des critères encore ouverts : le builder est relancé
  sur ces critères (deux fois au plus) avant toute vérification.
- Fin de tour sur une question (ask) : pas de vérification, ni de
  correction, avant la réponse de l'utilisateur ; idem si le builder pose
  une question pendant une correction.
- Un id de critère écrit entre guillemets (« "2" », « "#2" ») vise bien
  le bon critère au lieu de #0.
- Le panneau des critères montre « completed » (◐).
- Test de non-régression de la passe de vérification (code_verify_test.go).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:30:00 +02:00
MichaelandClaude Opus 5.5 9ccdc021cb Chiffrement : la discussion active et le mode Code ne deviennent plus illisibles
Activer le chiffrement de la mémoire chiffrait TOUTES les valeurs du
bucket des discussions, y compris des clés que le code lit et écrit en
clair (getStr/putStr) : le pointeur de discussion active, le mode
Chat|Code de chaque discussion, la puce « passer en mode Code ». Relues
comme du charabia, la discussion active devenait introuvable et le mode
Code disparaissait. Les critères, lus en clair eux aussi, s'effaçaient.

- Ces clés-pointeurs restent en clair (plainStoreKey) : ni rechiffrées, ni
  comptées comme « chiffrement incomplet ».
- Celles qu'une base existante a déjà chiffrées sont remises en clair dès
  le déverrouillage, avant le rechargement de la discussion active.
- Les critères passent par putStoreBytes/getStoreBytes : chiffrés comme le
  reste du fil, et lisibles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:27:43 +02:00
MichaelandClaude Opus 5.5 5e36b1cf82 Discussions : la liste montre la nouvelle dès le premier message
Repris d'AJEAN 0.17.6. La discussion est enregistrée au début du tour
(StartTurn persiste), mais la liste ne se rafraîchissait qu'à la fin de
la réponse. Elle se recharge maintenant à l'arrivée du message, groupée
quand plusieurs événements se suivent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:25:32 +02:00
MichaelandClaude Opus 5.5 2bbf0ba76d Tâches : « rappelle-moi dans 20 minutes » sans cron, à l'heure du navigateur
Repris d'AJEAN 0.17.5.

- task_create accepte in_minutes (dans N minutes) ou at (« HH:MM », prochaine
  occurrence, ou « AAAA-MM-JJ HH:MM ») pour une tâche unique, calculée côté
  serveur : le modèle n'a plus à écrire un cron ni à jongler avec les
  fuseaux pour un simple rappel. Elle s'écrit « @once AAAA-MM-JJ HH:MM » et
  se désactive après son passage ; manquée pendant un arrêt, elle part au
  redémarrage.
- Le navigateur envoie son fuseau (Europe/Paris…) avec chaque message ; il
  est retenu et sert aux tâches que l'IA planifie. Le conteneur tourne en
  UTC : « à 17h » tombait deux heures à côté. La réponse de task_create
  nomme le fuseau utilisé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:25:04 +02:00
MichaelandClaude Opus 5.5 8171899ac1 Contexte : fini le 400 « dépasse la fenêtre » qui bloquait la discussion
Repris d'AJEAN 0.17.5. Sur une fenêtre de 65k, un tour qui lit plusieurs
gros fichiers d'un coup passait de 60 % à plus de 130 % sans jamais
compacter, et l'erreur remontait telle quelle.

- Le test de compactage en cours de tour compte aussi les résultats
  d'outils de l'étape, que le moteur n'a pas encore vus. Sans usage côté
  serveur, l'estimation porte toujours sur tout l'historique.
- Tout résultat d'outil est borné à 30 000 caractères dans la vue du
  modèle, avec une mention qui l'invite à cibler. L'interface garde le
  résultat complet (« voir plus »).
- Dernier recours quand le moteur refuse encore le prompt et que le
  compactage n'a rien pu faire : shrinkToFit tronque les gros résultats
  d'outils puis retire les plus vieux échanges, vers 60 % de la fenêtre
  corrigés par la taille réelle lue dans l'erreur. Deux essais au plus.
- Tâches planifiées : la note de tâche passe en tête du message utilisateur
  au lieu du système. Le préfixe reste celui du chat et le cache de prompt
  de llama-server sert aux deux (l'amont mesurait 12 s de recalcul pour un
  « salut » après une tâche).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:23:21 +02:00
MichaelandClaude Opus 5.5 199d7f1e99 Chat : compactage sans redite, file multi-appareils, bascule par identifiant
Repris d'AJEAN 0.17.4.

- Compactage : la dernière demande du torse n'est plus réinjectée quand la
  queue contient déjà un message utilisateur (le modèle répondait une
  seconde fois à une vieille question), ni quand le résumé a échoué (elle
  est déjà dans le torse dégraissé et réapparaissait après ses propres
  résultats d'outils).
- Le texte écrit avant un appel d'outil n'est plus rangé deux fois dans
  l'historique du modèle (message tool_calls ET réponse finale) : du
  contexte gaspillé à chaque tour d'outil.
- Deux appareils qui envoient en même temps : le second message part en
  file au lieu d'un 409. Une tâche planifiée garde le refus.
- Un raisonnement qui reprend après du texte de réponse ouvre une nouvelle
  bulle sous la réponse au lieu de remplir l'ancienne, repliée au-dessus.
- Bascule de preset par identifiant : le numéro seul visait un autre
  preset quand la liste avait bougé sur un autre appareil. Le numéro reste
  accepté.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:21:18 +02:00
MichaelandClaude Opus 5.5 7f8c5d360f Agent : reprise après coupure, appels parallèles séparés, outils gardés hors 500
Repris d'AJEAN 0.16.4 et 0.17.4.

- Flux coupé en cours de réponse vers une API externe (Wi-Fi, VPN, proxy
  qui décroche) : le tour reprend au lieu d'être abandonné, jusqu'à 5 fois
  avec attente croissante. Rien d'affiché : la requête est rejouée (le
  raisonnement montré est retiré). Du texte affiché : il est rendu au
  modèle avec « continue là où tu t'es arrêté », et la suite s'ajoute à
  l'écran. Un outil à moitié écrit est clos puis réémis. Jamais pour le
  llama-server local : coupé, il a planté. Une coupure après le dernier
  chunk (finish_reason reçu) garde la réponse telle quelle.
- Appels d'outils parallèles : chaque morceau est rangé selon son champ
  index, plus selon sa position dans le chunk. Un serveur qui envoie un
  appel par chunk les fusionnait (noms écrasés, JSON « {…}{…} »).
- Outils coupés et « réponds maintenant » seulement sur un 500 (appel mal
  formé). Un 401, 429 ou 502 d'une API externe coupait les outils et
  rejouait le tour, masquant la vraie cause.
- Débit de décodage mesuré côté Loki quand le serveur n'envoie pas de
  timings ; le débit de lecture, inconnu, n'est plus affiché à 0.
- Windows : la connexion refusée (WSA 10061) est reconnue comme telle par
  la reprise réseau (TestConnexionRefuseeEstRejouee échouait sous Windows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:18:35 +02:00
MichaelandClaude Opus 5.5 2db0ecd431 Presets externes : vision, compactage, bench et badge pour une API distante
La reprise faite en parallèle sur l'autre branche (b667fd4, jamais poussée)
couvrait des trous de celle-ci. Ses ajouts, posés sur la version en place :

- chat_template_kwargs, propre à llama.cpp, ne part plus vers une API
  distante — ni au chat ni au résumé de compactage. Une API stricte
  (OpenAI) répond 400 à un argument inconnu : toute compaction échouait.
- Vision : case « le modèle accepte les images » (EXTERNAL_VISION) dans la
  fenêtre API externe. Elle remplace, en externe, MMPROJ et la sonde
  /props : un modèle distant multimodal ne pouvait jamais recevoir d'image.
- Le benchmark refuse de tourner sur un preset externe : healthCheck le dit
  prêt sans moteur, et la mesure tapait un port arrêté ou un moteur resté
  en vie, attribuée à tort à ce preset.
- Le badge de modèle des réponses porte le nom du modèle distant.
- Éditer le preset en service l'applique aussitôt (SavePresetApplying).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:13:56 +02:00
MichaelandClaude Opus 5.5 48b8193dca Chat : écrire pendant que l'IA répond, et deux fils ne fusionnent plus
Repris d'AJEAN 0.14.0, adapté.

AJOUT EN COURS DE RÉPONSE
Il fallait arrêter la génération pour glisser une précision (le serveur
répondait 409). Un message envoyé pendant une réponse part désormais EN
FILE : runChat l'injecte à la prochaine frontière d'étape (après un appel
d'outil) — le modèle en tient compte dans la SUITE de sa réponse — ou, si
le tour se termine avant, il devient le tour suivant, dans l'ordre. Le
bouton envoyer réapparaît à côté de stop dès qu'il y a du texte ; le
message s'affiche « en attente » au-dessus de la carte jusqu'à ce que le
flux le confirme. Stop abandonne la file (queue_dropped, le client retire
ses pastilles).

Écarts avec l'amont :
- Les messages en attente sont posés AU-DESSUS de la carte, pas dans le
  fil qui s'écrit encore (ils s'y seraient intercalés entre deux bulles).
- Dédoublonnage par identifiant d'envoi (cid) : l'UI réessaie un envoi dont
  la réponse s'est perdue sur le tunnel. Le 409 « déjà en cours » faisait
  office de garde ; sans lui, le réessai mettait le message deux fois en
  file.
- Une tâche planifiée qui occupe le modèle garde le refus 409 (sa fin ne
  dépile rien).
- Pas d'injection après un stop : la boucle repassait en tête une fois
  l'outil interrompu et journalisait le message en file (vu en test).
- runChat prend l'injecteur en paramètre variadique : les appels hors chat
  (tâches, vérification du mode Code, tests) ne changent pas.

DEUX FILS FUSIONNÉS
Un appareil déconnecté pendant qu'un autre changeait de discussion se
réabonnait avec un `from` hérité de l'ancienne : les Seq n'étant pas
globaux, la fin de la nouvelle discussion se greffait sur le début de
l'ancienne restée à l'écran. caught_up et reset portent maintenant l'id de
la discussion ; le client le renvoie (conv_id) et, s'il ne correspond plus,
le serveur ordonne un reset avant de rejouer. La garde « from au-delà du
dernier Seq » émet elle aussi ce reset (elle rejouait par-dessus l'écran
sans le vider).

Vérifié dans le navigateur avec un faux moteur : précision injectée après
l'outil dans le même tour, message en file devenu tour suivant, stop qui
abandonne la file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:11:40 +02:00
MichaelandClaude Opus 5.5 58c2da1302 Chiffrement : les résultats d'outils et les images du fil suivent la mémoire
Les deux reprises d'AJEAN arrivées en parallèle du chiffrement écrivaient
en clair à côté de discussions chiffrées.

- Résultats complets des outils (« voir plus ») : bucket toolres ajouté à
  encryptedBuckets, écrits et relus par putStoreBytes / getStoreBytesErr.
  Mémoire verrouillée : rien n'est écrit, le flux porte le résultat entier.
  Le nettoyage des orphelins ne tourne plus quand la mémoire est
  verrouillée : l'index des discussions revenait vide et TOUT passait pour
  orphelin.
- Images du fil (chatimg/) : même enveloppe que les pages mémoire ;
  verrouillée, l'image reste en base64 dans le message. Elles n'étaient
  jamais effacées : comme un fil rechargé perd ses images (stripImageParts),
  celles de plus de 24 h partent au démarrage et à chaque bascule de
  discussion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:10:56 +02:00
MichaelandClaude Opus 5.5 ae0e181711 Outils : aperçu dans le flux, « voir plus » à la demande, coupes annoncées
Repris d'AJEAN 0.15.1 → 0.15.7 (tool_results.go adapté : pas de
chiffrement chez nous).

VOIR PLUS
Chaque résultat d'outil partait en entier dans le flux (jusqu'à 12 000
caractères) et dans le journal rejoué à chaque ouverture : un long fil
d'agent en transportait des centaines. Le flux ne porte plus qu'un aperçu
de 1 600 caractères, la taille réelle (le « ~N tok » de la bulle reste
juste) et un id. Le résultat complet est rangé dans le bucket `toolres`,
sous « <discussion>.<aléa> » — un id propre, pas le tool_call_id que
certains parseurs recyclent. « voir plus » le charge au clic via
/api/chat/tool-result et lève le plafond de hauteur du bloc ; il se range
avec « copier » dans une barre au coin du résultat. Les résultats partent
avec leur discussion (convDelete) ; les orphelins sont nettoyés toutes les
200 écritures.

COUPES ANNONCÉES
- Terminal : une sortie de plus de 8 000 caractères était coupée en
  silence ; le modèle croyait tout voir. Elle commence désormais par
  « [sortie tronquée : N caractères au total, seuls les 8000 derniers sont
  gardés] ».
- MCP : l'amont transmet désormais la réponse en entier. On s'en approche
  sans renoncer au garde-fou (65k de contexte) : le plafond suit la
  fenêtre (CTX caractères, ≈ un quart de la fenêtre, jamais sous les
  12 000 d'avant), et la coupe dit la taille réelle.

Vérifié dans le navigateur avec un faux moteur qui appelle bash : aperçu
de 1 600 caractères, « voir plus » charge les 8 099, mention de coupe en
tête.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:08:46 +02:00
Michael 6aafeeea98 Revert "Résultats d'outils : aperçu + « voir plus », vrai compteur, MCP complet"
This reverts commit 91d1796615.
2026-10-02 23:08:31 +02:00
MichaelandClaude Opus 5.5 f54c304019 Chat : 20 derniers échanges au chargement, et le toucher revient sur iOS
Repris d'AJEAN 0.15.7.

HISTORIQUE PAGINÉ
Rouvrir une longue conversation d'agent rejouait TOUT le journal : des Mo
à travers le tunnel, et des milliers de bulles construites avant
d'afficher la moindre chose. Le client annonce désormais `tail` : au
chargement (et à l'ouverture d'une autre discussion), le serveur ne rejoue
que les 20 derniers échanges, précédés de {history_more: N}. Un bouton en
tête du fil redemande le rejeu complet en gardant la position de lecture.
Un client qui n'envoie pas `tail` (app relais…) reçoit tout, comme avant.

Écart avec l'amont : les états que le client garde et que la partie
masquée avait posés — mode Chat/Code, critères du mode Code — sont réémis
à la coupe (historyHead), ainsi que ctx_used à l'ouverture d'une
discussion. Sans ça, une discussion en mode Code rouverte s'affichait en
mode Chat, et la jauge de contexte à zéro.

iOS : LES TAPS PERDUS PENDANT UNE RÉPONSE
iOS annule un appui si la position de défilement change pendant qu'il a
lieu. Le fil se recalant en bas à chaque token, presque chaque tap tombait
dessus — y compris sur stop. Le suivi est suspendu pendant un contact (et
350 ms après), scrollTop n'est plus réécrit quand on est déjà en bas, et
stop agit dès l'appui sur écran tactile (anti-doublon pour le clic qui
suit).

Vérifié dans le navigateur sur 23 échanges : 20 affichés + « afficher les
3 échanges précédents », puis rejeu complet à position de lecture égale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:07:56 +02:00
MichaelandClaude Opus 5.5 51c791268d Fil long : retour au replay complet avant la reprise du fenêtrage par échanges
Annule c32fd40. Le replay borné aux 4000 derniers événements coupait au
milieu d'un tour, perdait l'état du mode Code (critères, jauge de contexte)
porté par la partie masquée et s'imposait à tous les clients. Le fenêtrage
par échanges repris d'AJEAN (commit suivant) coupe aux frontières d'échange
et renvoie cet état en tête.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:07:46 +02:00
MichaelandClaude Opus 5.5 04137411fa Discussions : la liste se dessine par pages de 40
Repris d'AJEAN 0.15.6, pour la partie qui nous concerne. Côté serveur, la
liste était déjà légère (l'index ne porte que des métadonnées, aucune
conversation n'est relue) ; c'est le DOM qui coûtait : des centaines de
lignes construites d'un coup à chaque rafraîchissement de la liste.

On en dessine 40, puis la suite quand la ligne « afficher N de plus »
arrive à l'écran (IntersectionObserver, ou clic). La discussion active
reste toujours visible, même au-delà. La recherche filtre toujours la
liste ENTIÈRE, qui reste en mémoire ; changer la requête repart de la
première page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:34 +02:00
MichaelandClaude Opus 5.5 f7a4ee71e7 Chat : les images ne sont plus réécrites en base64 à chaque tour
Repris d'AJEAN 0.15.7 (chat_images.go et son test), adapté.

Une image jointe (ou une capture de web_screenshot) vivait en base64 dans
les messages de la conversation, et persist() réécrit la conversation
ENTIÈRE à chaque fin de tour : une photo de téléphone, c'étaient des Mo
remis sur disque à chaque réponse. Elle est désormais rangée une fois sous
$LOKI_HOME/chatimg/<empreinte>.<ext> (hors du dossier de travail, que l'IA
peut vider) ; l'historique n'en garde que « loki-img:<nom> ». Juste avant
l'envoi, expandImageRefs remet les octets exacts (cache mémoire borné à
64 Mo) : pour le modèle, rien ne change.

DEUX ÉCARTS AVEC L'AMONT
- Copie à l'écriture : l'amont remplace l'URL en place. Chez nous, persist
  peut tourner pendant qu'un runChat sérialise ces mêmes maps (compactage
  en vol, bascule de projet) — « concurrent map read and map write », fatal.
  Les parties modifiées sont donc recopiées.
- Le rechargement reste éphémère : stripImageParts retire toujours les
  images d'une conversation rouverte (garde-fou contre un modèle sans vision
  qui lirait le base64 comme du texte). L'amont, lui, les garde ; on
  pourra le suivre plus tard si l'on veut.

Pas de migration des archives : une conversation rouverte est allégée par
stripImageParts, comme avant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:34 +02:00
MichaelandClaude Opus 5.5 1db987844e Chargement du modèle : --mlock seul ne coupe plus le mmap
UN VRAI BUG D'ABORD
normalizeLoadFlags traduisait --mlock seul en « --load-mode mlock ». Or,
pour llama.cpp (common/arg.cpp), « mlock » veut dire PAS de mmap + résident :
le modèle entier montait en RAM au lieu d'être mappé. L'ancien --mlock, lui,
gardait le mmap — son équivalent est « mmap+mlock ». Sur un modèle plus gros
que la RAM (Qwen3.8-Flash-Next, 82 Go pour 64 Go), un preset qui avait
coché « Garder en RAM » tournait donc à l'OOM dès qu'un moteur récent
prenait le relais. Correspondance alignée sur AJEAN 0.16.0 :
  --mlock seul → mmap+mlock · --no-mmap → none · les deux → mlock.

DANS LES DEUX SENS
Un moteur ancien (ou un fork) ne connaît pas --load-mode et sort en erreur
si on le lui passe : downgradeLoadMode le retraduit en anciens drapeaux
(dio, inconnu de ces moteurs, est abandonné avec un avertissement).

UN SÉLECTEUR À LA PLACE DE DEUX INTERRUPTEURS
« Garder en RAM » et « Charger tout en mémoire » ne disaient pas leur
combinaison. L'éditeur de preset propose les six modes de llama.cpp (auto,
mmap, none, mlock, mmap+mlock, dio), avec ce que fait chacun en sous-titre,
et écrit la forme moderne. Un preset aux anciens drapeaux s'affiche sur le
bon mode sans modification.

Au passage, q5_1 porte la mention « lent sur CUDA » dans la liste du cache
KV (voir le commit du port occupé : non accéléré en Flash-Attention).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:34 +02:00
MichaelandClaude Opus 5.5 f75f3cf27d Web : réponses compressées en gzip
Repris d'AJEAN 0.15.7 (web_gzip.go et son test, tels quels).

L'UI (~590 Ko de HTML/JS/CSS en un seul fichier) et les gros JSON
(historique relu, liste des sessions, journal) partaient en clair. gzip
les divise par 4 à 8 — ça se sent sur un téléphone en Wi-Fi ou à travers
le tunnel.

La décision se prend au premier octet, sur le Content-Type posé par le
handler : les flux SSE (chat, complétions /v1 en streaming) et les
binaires passent tels quels — un flux compressé retiendrait ses
événements dans le tampon de gzip. Flush reste transmis pour les réponses
progressives, et Unwrap laisse le WebSocket des postes distants atteindre
le Hijacker d'origine.

Seul l'écouteur local est enveloppé : le tunnel sert le mux nu, comme
avant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:34 +02:00
MichaelandClaude Opus 5.5 38802d9e21 Presets : une bascule faite sur un appareil se voit sur les autres
Repris d'AJEAN 0.13.6. Changer de preset sur le téléphone laissait l'onglet
du PC afficher l'ancien, sélection de la liste comprise, jusqu'à un
rechargement à la main. /api/status (sondé toutes les 5 s) expose l'id du
preset actif ; loadStatus rappelle loadPresets quand il change sans qu'on
y ait touché ici.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:34 +02:00
MichaelandClaude Opus 5.5 2c36fa6d2c Mémoire : un mode par projet, et un quatrième — « recherche »
Repris d'AJEAN 0.13.12.

PAR PROJET
Le mode mémoire était un réglage global de config.env. Il vit désormais
sur le projet (champ mem_mode) : un chantier de code peut couper la
mémoire pendant qu'un projet perso la garde proactive. Un projet sans
réglage propre — tous ceux d'avant — retombe sur l'ancien MEM_MODE global :
rien ne change tant qu'on n'y touche pas. L'API /api/memory et `loki
memory` agissent sur le projet actif ; l'UI le dit, et se recharge déjà au
changement de projet.

AUTO, EN DEUX SAVEURS
- « auto, injectée » (always, le défaut) : l'index des pages est en tête
  de conversation. La consigne dit maintenant d'y lire directement la
  bonne page ; mem_search ne sert plus qu'à chercher par contenu. Avant,
  on injectait l'index ET on exigeait une recherche préalable — un appel
  d'outil par tour pour retrouver ce que le modèle avait sous les yeux.
- « auto, recherche » (nouveau) : rien d'injecté, l'IA cherche avant
  chaque tâche. Contexte plus léger, démarrage plus rapide.
memProactive regroupe les deux là où seul « proactif » compte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:08 +02:00
MichaelandClaude Opus 5.5 4a1054c1b7 Mémoire : après un compactage, le modèle sait quelles pages il avait lues
Repris d'AJEAN 0.14.0 (chat_mem_pinned.go et ses tests, tels quels).

Le compactage résume le torse, résultats de mem_read compris. Une page qui
portait TOUTES les règles d'une tâche se retrouvait réduite à trois mots
dans le résumé, et le modèle, après compactage, les oubliait. Le rappel ne
recopie rien : il LISTE les pages lues (bornées aux 24 plus récentes) et
invite à les relire si elles concernent la tâche. Il s'accumule d'un
compactage à l'autre — l'ancien rappel est relu comme source, puisque le
mem_read d'origine a disparu.

Posé seulement en mode agent (sans lui, pas de mem_read pour y donner
suite). Comme le contexte projet, le rappel est sorti du bloc système
commun vers le premier message utilisateur (isProjectSystem) : il est
propre à la conversation et casserait sinon le cache du système partagé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:51 +02:00
MichaelandClaude Opus 5.5 626227f2f2 Projets : changer de projet ne recalcule plus tout le prompt
Repris d'AJEAN 0.15.8, adapté : chez nous le contexte projet n'est jamais
persisté, il est injecté à chaque tour sous forme de messages système
préfixés — ce qui rend le déplacement trivial et sans migration.

LE PROBLÈME
Sur un modèle hybride (Qwen3.5 et suivants, couches récurrentes), llama.cpp
ne peut reprendre un prompt déjà calculé qu'à un point de sauvegarde, et il
n'en pose qu'au DÉBUT d'un message utilisateur. La description du projet,
l'index mémoire et l'index des trackers étaient fusionnés dans le bloc
système : deux projets divergeaient AVANT le premier point de reprise, et le
premier message après un changement de projet recalculait tout (l'amont
mesure ~8 s pour 5 000 tokens sur un 27B).

LE CORRECTIF
normalizeSystemMessages sort ces messages (isProjectSystem, par leurs
préfixes) du bloc système et les place, dans un bloc <project_context>, en
tête du PREMIER message utilisateur de la séquence envoyée. Le système
commun reste identique d'un projet à l'autre, donc en cache. L'historique
persisté et l'affichage ne changent pas (copie, entrée non mutée — testé).

InjectSkills ne fusionne plus son préambule DANS un message projet placé en
tête (cas d'un preset sans prompt système) : il y aurait perdu son préfixe
et serait resté dans le bloc commun.

Le gain ne vaut qu'entre projets de même mode mémoire : la consigne mémoire
fait partie du système commun.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:51 +02:00
MichaelandClaude Opus 5.5 4602614be0 Chat : mode agent coupé = modèle brut, vraiment
Repris d'AJEAN 0.13.12 / 0.13.13. Couper le mode agent retirait bien les
outils, la mémoire et le préambule Loki, mais laissait passer deux choses :

- Le prompt système du PRESET et le contexte du projet (description, index
  mémoire, trackers). Ils décrivent des outils et une mémoire que ce mode
  n'a pas : le modèle, persuadé de pouvoir agir, écrivait des <tool_call>
  en clair dans sa réponse. Agent off, generate n'injecte plus rien — le
  modèle reçoit les messages nus, comme un llama-server direct.
- Un tool_call que le moteur parvient à parser alors qu'aucun outil n'a été
  annoncé (agent off, ou relance sans outils après un 500). Il était exécuté,
  répondait « outil inconnu », et la boucle repartait. Il est désormais
  ignoré : le modèle répond en texte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:51 +02:00
MichaelandClaude Opus 5.5 f117a466af Presets : fini le benchmark fantôme
Repris d'AJEAN 0.16.3. Les benchmarks sont rangés par id de preset. Un
preset dont on changeait le modèle — ou supprimé puis recréé sous le même
nom — affichait les mesures d'un AUTRE modèle, qui n'avaient rien à voir.

- benchMatchesPreset : un bench n'est affiché que si le modèle mesuré est
  celui du preset (le nom de fichier est enregistré avec la mesure depuis
  toujours, il ne servait simplement à rien).
- DeletePreset oublie le bench, comme il oubliait déjà le prompt système.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:51 +02:00
MichaelandClaude Opus 5.5 97378da7af Agent : trois filets de secours qui faisaient plus de mal que de bien
Repris d'AJEAN 0.15.5.

COMPACTAGE DE SECOURS
Sur n'importe quel refus du moteur — appel d'outil mal formé, modèle en
cours de chargement, erreur de template — runChat résumait ~75 % de la
conversation avant de rejouer, même quand elle tenait en trois messages.
contextOverflow ne laisse passer que les vrais débordements : libellés de
llama.cpp (« exceeds the available context size ») et des API
OpenAI-compatibles (context_length_exceeded), ou, à défaut de libellé, une
conversation déjà à 90 % de la fenêtre.

ARGUMENTS D'OUTIL ILLISIBLES
Un JSON tronqué était neutralisé en {} (indispensable : le template les
re-parse à chaque requête) PUIS exécuté tel quel, d'où des erreurs
trompeuses (« fichier manquant », « commande vide ») qui envoyaient le
modèle sur une fausse piste. L'appel n'est plus exécuté ; le modèle reçoit
une erreur qui dit exactement quoi renvoyer. Des arguments VIDES restent
valides (outil sans paramètre).

RELANCE SANS OUTILS
La consigne imposait le français. Elle demande désormais la langue de
l'utilisateur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:35 +02:00
MichaelandClaude Opus 5.5 e12f07426f Diff : le compteur de lignes dit enfin la vérité
Repris d'AJEAN 0.16.0 (chat_diff.go et son test, repris tels quels : le
fichier n'avait pas bougé chez nous depuis la 0.13.5).

- Un fichier de 500 lignes écrit par l'agent affichait « +500 » pendant la
  frappe puis retombait à « +120 » : le diff envoyé à l'UI est plafonné à 120
  lignes, et l'UI recomptait sur la version tronquée. lineDiff et addedDiff
  rendent désormais les VRAIS totaux, calculés avant la coupe, et ils
  voyagent dans le journal (added/removed) — donc survivent au refresh.
  Repli sur l'ancien décompte pour les conversations d'avant.
- Une retouche d'une ligne dans un bloc de plus de 400 lignes s'affichait en
  remplacement complet (le LCS n'y était pas tenté). Les lignes communes en
  tête et en queue sont écartées d'abord : on voit le vrai changement, avec
  trois lignes de contexte, et il n'est plus repoussé hors de la fenêtre.
- Un contenu terminé par un saut de ligne ne compte plus une ligne de trop,
  côté serveur comme dans la bulle en cours de frappe (bodyLineCount).

Le vérificateur du mode Code (code_verify.go) journalise les mêmes champs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:30 +02:00
MichaelandClaude Opus 5.5 20375d0e4c Moteur : port déjà occupé refusé, cache KV lent signalé
Deux reprises d'AJEAN 0.15.9, côté `loki serve`.

PORT OCCUPÉ
Un llama-server orphelin (un stop qui n'a pas tué, un relancement trop
rapide) peut encore tenir le port quand le suivant démarre. Deux moteurs
sur le même port se partagent alors les requêtes au hasard : VRAM saturée,
réponses du mauvais modèle. waitPortFree tente une connexion TCP (fiable
même en SO_REUSEADDR, là où un Listen de test réussirait), laisse 5 s à un
moteur qu'on vient d'arrêter pour libérer, puis refuse avec un message qui
dit quoi faire. argValue lit la DERNIÈRE occurrence de --port/--host, comme
llama-server, puisque EXTRA_ARGS peut les surcharger.

CACHE KV LENT
llama.cpp CUDA, compilé avec ses options par défaut (FA_ALL_QUANTS=OFF),
n'accélère en Flash-Attention que f16, bf16, q8_0 et q4_0 — et seulement à
l'identique pour K et V. C'est le cas du moteur de l'image server-cuda et de
ceux installés par OCI. q5_1 ou un couple mixte q8_0/q4_0 marchent mais sont
reconvertis en f16 à chaque pas. L'amont ne prévient que pour son binaire
précompilé ; chez nous tous les moteurs sont concernés, donc l'avertissement
part toujours dans loki-engine.log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:10 +02:00
MichaelandClaude Opus 5.5 b893d1a81f API : un site tiers ne peut plus piloter Loki en douce
Repris d'AJEAN 0.15.5. Sans clé de pilotage (le défaut), l'API /api était
ouverte à tout ce qui savait joindre le port — y compris une page web
quelconque ouverte dans un navigateur du réseau local. Un POST « simple »
(text/plain) ne déclenche aucune pré-vérification CORS : la page pouvait
changer des réglages et, mode agent actif, faire exécuter des commandes.

requireWebAuth passe désormais par crossSiteReject, AVANT le test de clé :
- Sec-Fetch-Site: cross-site → 403 ;
- Origin présent et différent de l'hôte appelé (ou « null ») → 403. curl,
  les scripts et les apps n'envoient pas d'Origin : non concernés ;
- sans clé seulement, l'hôte appelé doit être local (IP, localhost, nom sans
  point, .local/.lan/.home…, nom du conteneur) : c'est ce qui coupe le DNS
  rebinding, où un domaine malveillant se fait résoudre en IP locale.

DEUX ÉCARTS AVEC L'AMONT, DUS AU CONTENEUR
- LOKI_TRUSTED_HOSTS : derrière un reverse proxy, le nom public n'est ni
  local ni celui du conteneur. Plutôt qu'imposer une clé, on peut lister ce
  nom. Documenté dans le README.
- Le trafic du tunnel (marqué par withLocalAuth, authentifié par le relais)
  est dispensé du contrôle d'hôte : son Host est celui du relais.

À SAVOIR : un accès existant par nom de domaine SANS clé ni
LOKI_TRUSTED_HOSTS est désormais refusé (403, message explicite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:05 +02:00
Claude 513aca1c7b Version 0.13.0
Le rattrapage de l'amont (navigateur piloté, chiffrement de la mémoire,
notifications, tâches script, sous-agents, anglais) était parti sur main
sous le numéro 0.12.3 : huit fonctionnalités sous un numéro de correctif.

Bump des trois porteurs de version — la constante Go, versioninfo.json et
les .syso Windows régénérés (go generate) — et notes de release réécrites
pour 0.13.0. Sans ça, `loki update` et le bandeau de mise à jour comparaient
une version qui ne bougeait pas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-23 07:52:59 +00:00
Claude f1075030dd NOTICE : créditer OpenFox pour les sous-agents et le fenêtrage du fil
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 14:08:15 +00:00
Claude c4e399e101 Mode code : sous-agents (explorer, code-reviewer, planner)
Reprise de l'idée des sous-agents d'OpenFox, sur la mécanique déjà en place
pour la passe de vérification : un runChat isolé, non persisté.

L'outil `subagent` délègue une question bornée à un rôle qui travaille dans
SON propre contexte et ne rend que sa réponse. Sur un modèle local, c'est la
fenêtre de contexte qu'on sauve : « trouve où est géré le cache » coûte dix
lectures de fichiers qui restaient ensuite dans l'historique jusqu'à la
compaction, alors que seule la réponse comptait.

Les rôles explorer et code-reviewer, jusqu'ici définis mais jamais appelés,
deviennent utilisables. Tous les rôles délégués sont en LECTURE SEULE : pas
de write/edit (ce qui modifie le dépôt reste dans le fil principal, sous les
yeux de l'utilisateur), pas de subagent (aucune récursion), pas de mémoire ni
de web. Seul le planner pose des critères — son prompt le lui demande — et
marquer un critère « passé » reste le privilège de la passe de vérification.

Le rôle verifier n'est PAS délégable : le builder se décernerait son propre
satisfecit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 14:08:01 +00:00
Claude c32fd40f1a Longues discussions : ne rejouer que la fin, charger le début d'un clic
Inspiré du fenêtrage du fil d'OpenFox (2.0.151+). À chaque ouverture
d'onglet, le serveur rejouait TOUT le journal d'affichage : sur une
discussion de plusieurs centaines de tours, des dizaines de milliers
d'événements traversaient le flux, puis autant de bulles s'installaient dans
le DOM que le navigateur devait traîner à chaque rendu.

Le replay initial est désormais borné à ses 4000 derniers événements. Rien
n'est tronqué sur le disque : le serveur annonce combien d'événements sont
restés en arrière, l'interface l'affiche en tête du fil et le bouton
« charger le début » se réabonne en demandant le journal entier.

Jamais borné sur une reprise de flux (from > 0) : là, le client a déjà le
début à l'écran et attend la suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 14:04:58 +00:00
Claude 382c5dbdce Interface en anglais, par-dessus une source française
L'amont tient une table de clés FR/EN et marque chaque texte d'un data-i18n.
Reproduire ça ici demanderait de réécrire tous les écrans d'un coup, avec le
risque d'en casser un pour une clé oubliée — et un « settings.memory.title »
affiché en production.

Chemin additif : la source RESTE le français, et « English » applique un
dictionnaire sur le texte affiché (correspondance exacte, nœud par nœud).
Une chaîne absente du dictionnaire reste en français ; le dictionnaire
s'enrichit sans toucher au reste de l'interface.

Jamais traduits : le fil de discussion (#chat), le code, les zones de
saisie, et tout ce qui porte data-no-i18n. Un observateur couvre les
panneaux rendus en JS après le chargement. Revenir au français recharge la
page — le texte d'origine a été remplacé dans le DOM, c'est le moyen sûr de
le retrouver intact.

Couverture de départ : navigation des réglages, intitulés de sections,
libellés de lignes, boutons et interrupteurs. Sélecteur dans Apparence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 14:01:36 +00:00
Claude fa69dac77f Chiffrement de la mémoire, snapshots, sauvegarde chiffrée en fichier
Reprise d'AJEAN (mem_crypto/mem_vault/mem_store/mem_io/mem_migrate/
mem_snapshots/mem_health/mem_fsutil + backup_bundle), adaptée à Loki.

Chiffrement à enveloppe : une DEK tirée une fois chiffre les données en
AES-256-GCM ; elle est enfermée dans un coffre par une KEK dérivée en
Argon2id. Ce qui ouvre le coffre : la clé de pilotage de l'appareil (le
serveur n'en a que l'empreinte, il ne peut pas ouvrir seul) ou la clé de
récupération donnée une fois. La DEK ne vit qu'en RAM.

Périmètre chiffré, choisi pour Loki : les pages mémoire, les discussions
(journal ET index, qui porte les titres), les blocs archivés au compactage
— du verbatim de conversation — et les trackers. Pas les buckets de
réglages : le coffre lui-même y vit, les chiffrer serait une boucle.

Verrouillé, rien n'est écrasé : putStoreBytes REFUSE d'écrire du clair
par-dessus du chiffré, la liste des discussions se lit vide et les pages
s'affichent « 🔒 chiffré ». Le déverrouillage recharge la discussion et
rattrape ce qui serait resté en clair. Une migration interrompue reprend au
démarrage, un snapshot est pris avant chaque bascule, et aucune donnée n'est
supprimée avant relecture vérifiée de son remplaçant.

Piège corrigé au passage : chiffrer À L'INTÉRIEUR d'une transaction bbolt se
bloquait sur le verrou de la base (memEncActive relit la config, donc la
base). L'état du chiffrement est désormais résolu AVANT la transaction
(memEncoderNow), qui ne reçoit plus qu'un encodeur pur.

Sauvegarde : le paquet chiffré {mémoire, presets, réglages} s'exporte et
s'importe en FICHIER (/api/backup/export, /api/backup/import). La
sauvegarde vers le relais ajean.link n'est pas reprise — c'est le service de
l'auteur amont ; ici le fichier reste chez soi.

Le dossier mémoire n'était déjà plus joignable qu'aux outils mem_* : c'était
la condition de ce chiffrement (un `cat memory/…` aurait rendu du binaire).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:59:10 +00:00
Claude e9ec8ae3a8 Notifications Web Push (+ manifeste PWA)
Reprise d'AJEAN : le serveur pousse une notification vers les navigateurs
abonnés, directement via leur service de push — donc app fermée et téléphone
verrouillé, là où une notification côté page ne peut rien (un onglet caché
relâche son flux SSE).

Deux déclencheurs : la fin d'un tour utilisateur (sauf interruption par le
bouton stop : celui qui a coupé est devant l'écran) et — ajout propre à Loki
— la fin d'une TÂCHE PLANIFIÉE, succès comme échec. C'est le cas qui compte
le plus : une tâche tourne justement quand personne ne regarde.

Clés VAPID générées à la première demande et rangées dans la base ;
abonnements persistés et purgés quand le service de push répond 404/410.
Corps de notification générique, sans extrait de réponse : elle transite par
Apple ou Google. /sw.js et /manifest.webmanifest sont servis à la racine
(un service worker doit venir de l'origine) ; le worker ne fait QUE recevoir
les push, sans cache — mettre l'UI en cache servirait une interface périmée
après une mise à jour de l'image.

Interrupteur dans Réglages → Mode agent, à armer sur chaque appareil. L'UI
dit ce qui manque plutôt que d'échouer : HTTPS requis, notifications
bloquées, ou iPhone à ajouter d'abord à l'écran d'accueil.

Nouvelle dépendance : github.com/SherClockHolmes/webpush-go (RFC 8291).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:37:16 +00:00
Claude 74fb1fc781 Tâches : scripts planifiés sans modèle, et l'IA planifie la sienne
Deux reprises d'AJEAN autour des tâches planifiées.

Dossier de scripts (/data/scripts) : un dossier durable À CÔTÉ de memory,
hors du workspace. Le workspace est jetable — supprimer une discussion
emporte ses fichiers — donc un script qu'on veut garder n'y avait pas sa
place. Le briefing machine l'annonce à l'IA, qui y écrit et y lance ses
scripts normalement.

Tâche « script seul » (Task.Kind/Script) : le planificateur lance le script
sans charger le modèle ni consommer un token, sa sortie devient le
compte-rendu, et l'UI l'affiche comme n'importe quelle tâche. Elle tourne
hors du verrou de génération — d'où un registre à part pour l'afficher « en
cours » et l'arrêter (/api/tasks/stop), et un « tester » qui n'attend ni le
verrou ni le moteur. Sélecteur Consigne IA / Script seul dans la modale.

Outils task_list/create/update/delete : l'IA se donne elle-même rappels et
veilles récurrentes, cloisonnés par projet (dans un projet, elle ne voit et
ne pilote que ses tâches). Le budget du préambule passe de 8600 à 11000
caractères, avec le palier documenté dans le test.

Dossier mémoire réservé à ses outils : bash, write et edit ne le touchent
plus (guardToolOnly*). Un `cat memory/…` contournait l'index MEMORY.md, et
c'est la condition d'un chiffrement de la mémoire à venir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:33:39 +00:00
Claude 91d1796615 Résultats d'outils : aperçu + « voir plus », vrai compteur, MCP complet
Le flux n'envoie plus tout le résultat d'un outil : un aperçu de 1600
caractères, sa taille RÉELLE et l'id de l'appel. Le bouton « voir plus »
charge le reste à la demande (/api/chat/tool-result), et déplie vraiment le
bloc (plafond de 280 px levé) au lieu de le laisser scroller. « voir plus »
et « copier » vivent dans la même barre, ancrée en bas à droite d'un
bloc-parent non scrollant : elle reste au coin quand on scrolle le résultat.

Le compteur « ~N tok » de la bulle disait la taille de ce que l'UI avait
reçu, donc toujours le plafond sur un long résultat. Il lit maintenant
result_chars, la taille réelle.

MCP : plus de troncature à 12000 caractères avant le modèle (même règle que
la lecture mémoire). Un serveur MCP est configuré exprès pour ce qu'il rend ;
le couper au milieu rendait la réponse inutilisable. L'UI, elle, n'en affiche
que l'aperçu.

Reprises d'AJEAN 0.15.1, 0.15.2 et 0.15.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:24:53 +00:00
Claude 00b2350fb9 Navigateur piloté, images redressées : reprises d'AJEAN
L'amont (AJEAN 0.15) donne à l'IA le contrôle d'un navigateur ; Loki
embarquait déjà un Chromium pour `web_screenshot` sans jamais le piloter.

Contrôle du navigateur (computer_use.go, computer_cdp.go, browser_grid.go,
portés d'AJEAN) : l'IA ouvre une page en CDP, en reçoit les éléments
interactifs NUMÉROTÉS et agit par numéro (browser_open/snapshot/find/click/
type/key/scroll). Aucune vision requise — un petit modèle texte s'en sort.
Avec un projecteur chargé s'ajoutent browser_screenshot (image quadrillée)
et browser_click_xy. Interrupteur dédié (Réglages, `loki computer`,
/api/computer), sous le mode agent : cliquer et taper dans une page sont des
actions réelles, même niveau de confiance que bash.

Adaptation au conteneur : chromePath cherche D'ABORD le Chromium de
Playwright (PLAYWRIGHT_BROWSERS_PATH, /opt/pw-browsers), le seul navigateur
de l'image — sinon la fonctionnalité se déclarait absente là où le
navigateur est présent. LOKI_CHROME force un chemin, LOKI_CU_HEADFUL ouvre
une vraie fenêtre.

Préparation des images (web_upload_orient.go, porté d'AJEAN) : l'orientation
EXIF est cuite dans les pixels — le projecteur l'ignore et voyait les photos
de téléphone couchées — et le grand côté ramené sous 1568 px. Appliquée aux
pièces jointes, à see_image et aux captures.

Dédup des appels d'outils : bash, bash_bg, bash_tail, see_image et les
browser_* ne sont plus court-circuités sur un appel identique. Relancer la
même commande après avoir modifié un fichier est légitime, et un outil qui
porte une image dans un message à part renvoyait « [déjà fait] » SANS
l'image — le modèle tournait en boucle.

Mode code : le builder ne publie plus de lui-même (pas de commit/push/reset/
rebase ni de redémarrage de service sans demande explicite), reprise de la
leçon d'AJEAN 0.15.4 ; l'inspection en lecture seule reste encouragée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:19:37 +00:00
Claude b5904907e8 Reprise réseau, presets externes, see_image
Trois apports repérés chez AJEAN (v0.13.8) et OpenFox (2.0.118), portés et
adaptés à Loki.

## Reprise réseau du tour (llm_retry_net.go)

La requête de complétion partait une fois : un Do() qui échoue ou un statut
d'erreur tuait le tour. Les messages d'erreur le disaient eux-mêmes —
« réessaie dans quelques secondes » — autrement dit on demandait à
l'utilisateur de refaire à la main ce que le code pouvait faire seul. Une
tâche planifiée tombée pendant un redémarrage du moteur échouait pour de
bon, sans personne pour recliquer.

Trois reprises consécutives, 0,8 → 1,6 → 3,2 s, plafonnées, interruptibles
par un /stop. La règle de sûreté ne souffre pas d'exception : on ne rejoue
que TANT QU'AUCUN OCTET N'A ÉTÉ DIFFUSÉ, sinon la moitié de la réponse
déjà chez l'utilisateur serait dupliquée. Le compteur repart à zéro dès
qu'une réponse arrive.

500 n'est pas un statut de reprise : c'est ce que llama.cpp rend pour un
appel d'outil malformé ou un prompt trop long, deux échecs déterministes
que les filets sémantiques traitent déjà. Restent les codes qui disent
« pas maintenant » : 429, 502, 503, 504.

## Presets externes (backend_external.go, web_external.go)

Un preset avec EXTERNAL=1 route le chat vers une API OpenAI-compatible
distante (OpenAI, Groq, OpenRouter, un vLLM sur une autre machine) au lieu
du llama-server local. C'est un preset COMME UN AUTRE : même liste, même
bascule, même prompt système par preset. La différence ne vit qu'à deux
endroits — l'inférence (resolveChatEndpoint) et la bascule, qui arrête le
moteur local au lieu de le redémarrer.

La clé du serveur local ne part jamais chez un tiers : chaque endpoint
porte la sienne. La clé du preset n'est jamais renvoyée en clair à
l'interface, et un champ vide ne l'efface pas — il faut y avoir touché.
Une fenêtre dédiée plutôt que l'éditeur habituel : un modèle distant n'a
ni quantification, ni couches GPU, ni moteur. Un bouton teste la connexion
avant d'enregistrer, et rend le message de l'API plutôt que le JSON brut.

Le résumé de compactage part au même endroit que le chat : le laisser
taper le moteur local aurait cassé toute compaction sur un preset externe.

## see_image (chat_vision_tool.go)

Loki savait voir une pièce jointe et une capture qu'il venait de prendre,
mais pas un fichier qui dort sur le disque : « regarde ~/photos/bug.png »
n'avait aucune réponse, `read` rendant des octets binaires. L'outil charge
l'image et la réinjecte dans un message utilisateur multimodal — même
chemin que les pièces jointes.

Même règle ÉPHÉMÈRE que les captures (et non celle de l'amont, qui persiste
l'image) : l'image va dans le tour en cours, pas dans l'historique. Un
base64 persisté repartirait à chaque tour et finirait par dépasser la
fenêtre pour de bon.

Le marqueur de perte à la compaction existait déjà mais n'offrait qu'un
recours, « reprends la capture » — ce qui enverrait photographier une page
web alors que l'image perdue est un PNG du disque. Formulation généralisée.

## Au passage

toolCallLabel est extrait de runChat. Cette table nom d'outil → argument a
une double fonction — libellé affiché ET argument principal — donc un outil
absent s'exécute sur une chaîne vide : see_image répondait « chemin de
fichier manquant » quoi qu'on lui passe, sans que rien d'autre ne bronche.
Une table pareille se teste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129sffVC43rezAXUMQuzUog
2026-09-12 21:05:19 +00:00
Claude d1626781f9 Chargement de la conversation : compter les reprises, pas les millisecondes
TestLoadConversationAbsenceLegitimeResteRapide vérifie qu'une absence
légitime — première installation, rien d'enregistré — ne déclenche aucune
reprise. Il le mesurait au chronomètre : « moins de 250 ms », la durée
d'une attente de reprise.

Le temps de mur ne dit pas ça. Il mêle le coût du PREMIER essai (créer et
ouvrir la base bbolt, fsync compris) à l'attente qu'on cherche à exclure.
Sur un runner CI chargé, cet essai unique a pris 435 ms : le test a échoué
alors qu'aucune reprise n'avait eu lieu, et le même commit passait une
minute plus tôt sur un autre runner.

LoadConversation compte désormais ses reprises (loadConvRetries), et le
test lit ce compteur. Ce qu'il affirme est enfin ce qu'il mesure, et il ne
dépend plus de la charge de la machine.

Le test de la base illisible vérifie le compteur dans l'autre sens (quatre
reprises attendues) : sans ça, un compteur bloqué à zéro rendrait la
première assertion vraie sans rien prouver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129sffVC43rezAXUMQuzUog
2026-09-12 20:25:48 +00:00
Claude 9bdaf00ca4 Modèles : un .gguf gardé se réutilise et s'efface
Un modèle téléchargé depuis l'éditeur d'un preset survit à la suppression
de ce preset (c'est voulu : on le réutilise ailleurs). Il devenait pourtant
inatteignable — impossible de refaire un preset dessus, impossible de
l'effacer.

Trois causes, trois correctifs.

1. « Le modèle existe déjà » n'est plus une erreur. La recherche Hugging
   Face proposait le quant, le clic lançait la sonde, et le téléchargement
   répondait en rouge « le modèle existe déjà » — fin du parcours. La sonde
   constate maintenant la présence du fichier (sans même sortir sur le
   réseau) et renvoie la valeur à écrire dans MODEL= : l'interface le
   SÉLECTIONNE, et la file continue (le projecteur vision, par exemple).
   Les quants déjà présents portent une pastille « déjà installé » dans la
   liste du dépôt, avant le clic.

2. Le sélecteur de modèle reconnaissait mal ce qu'il avait sous les yeux.
   /api/models comparait le dossier de chaque .gguf à LokiHome() alors que
   les téléchargements atterrissent dans $LOKI_HOME/models : la comparaison
   ne pouvait jamais être vraie. Conséquences : l'étiquette « dossier loki »
   ne s'affichait nulle part, et un preset écrit MODEL=modele.gguf
   s'affichait « introuvable ; ajoute son dossier ci-dessous » — le fichier
   étant juste à côté. Un nom simple est désormais résolu comme le fait le
   moteur : dossier de loki d'abord, puis les autres.

3. Une liste « Modèles installés », dans le groupe Modèle de l'éditeur.
   La route /api/models/delete existait depuis longtemps ; aucun bouton ne
   l'appelait. Le seul moment où un .gguf pouvait disparaître, c'était en
   cochant « supprimer aussi le fichier » à la suppression de son preset.
   La liste montre taille, dossier, tranches manquantes, et qui s'en sert :
   le modèle en service ne s'efface pas (le moteur l'a ouvert, la place ne
   serait même pas rendue), celui que des presets nomment prévient en les
   nommant puis obéit. La suppression dit ce qu'elle a libéré.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129sffVC43rezAXUMQuzUog
2026-09-12 20:20:33 +00:00
MichaelandClaude Opus 5 54468b3336 Dictée : Parakeet remplace whisper.cpp
Parakeet TDT 0.6B v3 (NVIDIA), servi par sherpa-onnx. La v3 et non la v2 :
c'est la seule des deux qui parle français — la v2 est anglais seul.

CE QUI DISPARAÎT DU DOCKERFILE
Deux étapes de compilation, dont une CUDA de ~200 fichiers nvcc qui a été tuée
par l'OOM du runner plus d'une fois, et avec elles tout l'appareillage de
garde-fous qu'elles réclamaient (GGML_NATIVE=OFF contre le SIGILL en
production, bornage des architectures CUDA, deux binaires CPU/CUDA à choisir à
l'exécution). À la place : le téléchargement d'un binaire statique de 35 Mo,
version épinglée et empreinte SHA-256 vérifiée — le binaire s'exécute sur la
machine de l'utilisateur, une release remplacée en amont ne doit pas passer en
silence.

CE QUI CHANGE DANS LE CODE
La forme est la même — un processus local supervisé, éteint après dix minutes
d'inactivité. Le dialogue, lui, change : whisper-server exposait du HTTP
multipart, sherpa-onnx n'expose qu'un WebSocket dont le protocole tient en deux
entiers et des flottants. D'où un client WebSocket et une conversion WAV →
float32 côté serveur. Le parcours des blocs du WAV n'est pas du zèle : l'offset
44 codé en dur transforme un bloc LIST intercalé en craquement au début de
chaque phrase.

DEUX RÉGLAGES DISPARAISSENT, ET C'EST LE MOTEUR QUI L'IMPOSE
La LANGUE : Parakeet la détecte lui-même, il n'a aucun drapeau pour la forcer.
Le réglage n'aurait servi qu'à mentir. À surveiller : whisper avait précisément
écarté la détection automatique parce qu'elle se trompait sur des tranches
courtes.
Le GPU : le build livré est le statique CPU. Annoncer un sélecteur de carte
sans pouvoir l'honorer serait pire que de ne rien annoncer — et un modèle de
0,6 B en int8 sur des tranches de quelques secondes n'en a pas besoin, la carte
reste au moteur de chat.

MIGRATION
Un réglage enregistré du temps de whisper retombe sur le défaut au lieu de
casser la dictée. Les modèles ggml de /data/whisper/ ne servent plus à rien
mais ne sont PAS effacés : ce sont des données que personne n'a demandé de
perdre. Ils sont à supprimer à la main.

Le paquet UI regénéré ici couvre aussi les sources du commit précédent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:34:57 +02:00
MichaelandClaude Opus 5 17f8fa8f34 Mémoire longue (recall), projets et trackers
Trois apports repris d'AJEAN 0.12.9 → 0.13.5, adaptés au fork.

MÉMOIRE LONGUE DE LA CONVERSATION (chat_recall.go)
Le compactage résumait, donc perdait. Chaque gros bloc du torse est désormais
ARCHIVÉ verbatim sous un identifiant court (r7…) dans bbolt AVANT d'être
résumé ; le résumé cite l'id, et le modèle le rappelle avec recall(id) ou le
retrouve par mots-clés avec recall_search. Le contexte reste plat, l'archive
grossit sur disque. En mode code, un fichier lu ou un diff produit tôt dans la
session n'est plus perdu au compactage suivant.
Au passage : garde-fou anti-résumé-dégénéré, budget de résumé indexé sur la
fenêtre, queue ramenée à 20 % (compacter plus large ne coûte plus de perte), et
fin de l'épinglage du 1er message user — le modèle répondait à l'ancienne
demande au lieu de continuer la tâche en cours.

PROJETS (projects.go)
Un projet cloisonne une mémoire, ses discussions et ses trackers. La couture
est memoryDir(), qui pointe sur le projet actif : tout le code mémoire en
hérite sans le savoir. Migration automatique au premier démarrage (memory/*.md
→ memory/generale/, discussions et tâches orphelines rattachées). Une tâche
planifiée vise un projet et l'exécution le force, pour qu'une veille n'écrive
pas dans la mémoire du chantier affiché à l'écran.

TRACKERS (tracker.go)
3e type de mémoire : les données datées qui s'accumulent. On ne les lit jamais
en entier — consultation par niveaux (vue d'ensemble → année → mois →
événements), et la dernière valeur de chaque tracker est donnée d'emblée au
modèle, qui répond sans appeler l'outil.

Aussi : index MEMORY.md tenu par le CODE et injecté en tête de conversation
avec la description du projet (le modèle ne peut plus le désynchroniser) ;
prompt système rattaché au PRESET et non plus global — stocké en base, pas
dans le .env, qui ne saurait pas porter un texte multiligne.

Deux correctifs du lot précédent voyagent ici, faute de pouvoir séparer les
fichiers : msgText signale la présence d'une image au compactage, et le
garde-fou « pensé sans agir » passe à deux relances (nudgeCount/maxNudges).

Le paquet UI est regénéré dans le commit suivant, qui touche les mêmes sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:34:33 +02:00
MichaelandClaude Opus 5 c45ba615fa Démarrage, captures, postes distants : correctifs portés d'AJEAN
- Au démarrage, une lecture ratée de la base (verrou bbolt transitoire pendant
  le chevauchement des process au redémarrage du conteneur) était confondue
  avec une conversation absente. Pire que l'amont chez nous : getStr rend ""
  dans les deux cas, donc convEnsureActive forgeait un NOUVEL identifiant et
  l'écrasait — le fil en cours devenait orphelin, en silence. On sonde
  désormais la base avec son erreur AVANT toute écriture, on réessaie quatre
  fois, et un échec durable est journalisé sans que rien ne soit touché.

- Une capture d'écran disparaissait sans laisser de trace : stripImageParts
  aplatissait le message en ne gardant que sa légende. Le marqueur
  imageLostMarker rend la perte VISIBLE, pour que le modèle sache reprendre
  une capture au lieu de la redécrire de mémoire.

- maxLogEvents 20000 → 200000 : un seul tour à très long raisonnement
  tronquait déjà le journal de rejeu, et l'utilisateur perdait le début de sa
  conversation à l'écran.

- Keepalive WebSocket des postes distants (ping toutes les 25 s, des DEUX
  côtés). Sans lui, un poste au repos était coupé au bout de ~60-100 s par les
  intermédiaires qui ferment les canaux inactifs, puis reconnecté après
  backoff — les déconnexions à répétition sur tous les postes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:33:55 +02:00
MichaelandClaude Fable 5 2d4f5378c0 Presets : modifier le preset en service l'applique aussitôt
CTX passé à 100000 dans l'éditeur, « enregistré »… et la carte du chat
qui affiche toujours 32768. Seul le fichier du preset changeait :
config.env gardait l'ancienne valeur, le moteur tournait avec, et plus
aucun preset n'était détecté actif (empreinte différente). Il fallait
penser à rebasculer dessus à la main.

SavePresetApplying décide AVANT l'écriture si le preset édité est celui
en service (empreinte de l'ancien fichier == configuration courante) ;
si oui, la nouvelle version est installée comme une bascule
(applyPresetFile : réglages machine et moteur préservés) et le service
redémarre en arrière-plan. Un preset inactif ou nouveau reste un simple
fichier. L'UI le dit et rafraîchit l'état — la jauge de contexte suit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFhcmBMUXdK6UesdGUgFPH
2026-08-30 17:13:19 +02:00
MichaelandClaude Fable 5 9d2ff33943 Fichiers : le panneau se redessine seul
Il ne se remplissait qu'à l'ouverture ; tout ce que l'agent écrivait
ensuite (rapports, captures, scripts) et chaque pièce jointe déposée
restaient invisibles tant qu'on ne cliquait pas « rafraîchir ».

filesOnActivity, regroupé à 400 ms : appelé à chaque résultat d'outil,
en fin de tour et après un dépôt. Rien pendant le rejeu du journal ni
panneau fermé — l'ouverture recharge de toute façon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFhcmBMUXdK6UesdGUgFPH
2026-08-30 17:13:19 +02:00
MichaelandClaude Fable 5 475d523e4a Journal : outils coalescés, curseur de replay borné, export allégé
Trois maux d'une conversation agentique longue :

- Chaque appel d'outil s'écrivait en de nombreux événements (annonce,
  frappe du corps, arguments) jusqu'au done=true qui porte déjà l'état
  final. Le journal enflait jusqu'à maxLogEvents et tronquait les plus
  VIEUX événements : les premiers messages disparaissaient à l'affichage.
  Seul le done=true est conservé au compactage.
- Un événement non-outil (stats, raisonnement) glissé entre l'annonce et
  le résultat faisait émettre l'outil DEUX fois au replay et à l'export
  Markdown. L'annonce reste en attente jusqu'au done.
- Ouvrir une session plus ancienne avec le curseur de la précédente
  (Seq plus élevés) sautait tout : conversation vide. Curseur au-delà du
  dernier Seq → on repart du début.
- L'export JSON embarquait les images en base64 (fichier énorme) : les
  pièces jointes sont réduites à leur descriptif.

Repris de l'amont AJEAN v0.12.7, avec ses tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN
2026-08-30 15:47:10 +02:00
MichaelandClaude Fable 5 3edd356f34 Mémoire : recherche par couverture multi-mots et rareté des termes
Un mot fréquent noyait les bonnes pages : la recherche ne comptait que
les occurrences. Elle pondère maintenant chaque terme par sa rareté dans
la mémoire et favorise les pages qui couvrent plusieurs mots de la
requête.

Repris de l'amont AJEAN v0.11.7, avec ses tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN
2026-08-30 15:47:10 +02:00
MichaelandClaude Fable 5 dfdee26e4c GPU : --list-devices qui plante en OOM faisait disparaître la 2e carte
Moteur qui tourne, carte déjà pleine : l'énumération des cartes plante
en « CUDA error: out of memory » et ne rend qu'une liste tronquée — le
groupe Cartes graphiques de l'éditeur se cachait (moins de deux cartes)
et le tensor-split était perdu après un simple rechargement de l'UI,
qui vide le cache mémoire.

La dernière énumération réussie est persistée dans $LOKI_HOME/devices.json
et servie en repli (stale) quand le moteur sort en erreur. Identité, ordre
et mémoire totale sont des faits matériels stables ; seule la mémoire
libre y est périmée, sans importance pour répartir.

Repris de l'amont AJEAN v0.10.8 et v0.11.4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN
2026-08-30 15:47:10 +02:00
MichaelandClaude Fable 5 cfe70f2b70 Presets : un switch Raisonnement laissé sur off ne l'écrivait jamais
onchange ne part que sur une interaction : créer un preset et laisser
l'interrupteur décoché n'écrivait pas REASONING=, et la clé absente
laisse le moteur suivre le gabarit du modèle — il raisonnait malgré le
switch affiché sur off. À l'enregistrement, l'état de l'interrupteur
est désormais matérialisé : off explicite, ou on si aucune valeur active
plus précise (auto/deepseek) n'est déjà là.

Repris de l'amont AJEAN v0.12.1 (issue #46).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN
2026-08-30 15:47:10 +02:00
MichaelandClaude Fable 5 b4f4135101 Presets : la section « Moteur » ne servait plus à rien
Depuis 6f6a321 le moteur est un réglage de machine : le preset ne
l'impose plus, la bascule garde le courant. En conteneur l'éditeur ne
proposait de toute façon qu'une seule case, « Image », que personne ne
pouvait changer — et le BIN figé dans le preset servait encore à lister
les cartes graphiques : un vieux preset interrogeait le moteur de
l'image au lieu du moteur mis à jour.

Le groupe disparaît de l'éditeur ; la liste des GPU interroge le moteur
courant (config_bin), mémorisé au préchauffage. CSS mort retiré.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN
2026-08-30 15:44:55 +02:00
MichaelandClaude Fable 5 6f6a3218ad Presets : changer de modèle ramenait le moteur de l'image
Le moteur mis à jour depuis l'interface (Réglages → Moteur, BIN →
/data/engine/server-cuda-b10680/llama-server) ne tenait pas : à la
bascule de preset suivante — changer de modèle, tâche planifiée —
/app/llama-server revenait, et le modèle qui chargeait cinq minutes
plus tôt mourait sur « unknown model architecture: 'qwen4exp' ». Le
journal alternait les deux moteurs sans qu'aucun réglage visible ait
bougé, et le panneau Moteur affichait « fourni par l'image » juste
après un « ✓ moteur mis à jour ».

Cause : chaque preset créé depuis l'UI embarque BIN (newPresetSeedKeys),
donc le chemin de l'image de l'époque, et applyPresetFile remplace TOUTE
la configuration par le preset — BIN n'était pas dans preservedKeys.

Le moteur est un réglage de machine : le courant est conservé à la
bascule, sauf si le preset désigne un backend personnalisé (compilé pour
un modèle précis — là, c'est un vrai choix par modèle, il gagne). Et un
nouveau preset ne fige plus le moteur de l'image ni un moteur téléchargé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01961iDyM6pwn2dE2SW23gYX
2026-08-30 08:27:42 +02:00
MichaelandClaude Fable 5 78c258ab45 Entrypoint : le moteur mis à jour ne survivait pas à un redémarrage
docker-entrypoint.sh reposait BIN=/app/llama-server à chaque démarrage
du conteneur, sans regarder ce qu'il y avait avant. L'intention était
saine — une mise à jour d'image ne doit pas laisser un BIN obsolète —
mais elle écrasait aussi le moteur téléchargé depuis l'interface
(« mettre à jour le moteur », /data/engine/<version>/), pourtant choisi
par l'utilisateur et toujours présent sur le volume.

Symptôme vécu : Qwen3.8-Flash-Next chargeait avec server-cuda-b10680 ;
au redémarrage suivant, retour silencieux au moteur de l'image et
« unknown model architecture: 'qwen4exp' », sans qu'aucun réglage ait
bougé. Le journal alternait /app et /data/engine sans raison visible.

On ne garde BIN que s'il désigne un moteur installé sous
$LOKI_HOME/engine/ et encore exécutable ; sinon, comportement d'avant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01961iDyM6pwn2dE2SW23gYX
2026-08-30 08:02:13 +02:00
MichaelandClaude Fable 5 33de4a1144 VRAM : le bilan mentait, un GET coupait le moteur, la dictée bloquait
Relecture du bouton « Libérer la VRAM » (009b585) — six défauts, tous
sur la même feature :

- gpuUsedSettled comptait une lecture ratée de nvidia-smi comme « 0 Mo »
  (pilote en réinitialisation juste après l'arrêt) : freed = before, et
  l'interface annonçait 14 Gio rendus alors que rien n'avait bougé. Elle
  rendait aussi la main au premier palier, AVANT que le pilote ait réagi
  (taskkill et Process.Kill reviennent avant le ménage CUDA) : « 0 Mo »
  après un déchargement qui marchait. gpuSettle saute les lectures
  ratées, n'accepte un palier qu'une fois la baisse observée, et est
  testable (sampler injecté).
- /api/vram/unload et /reload acceptaient GET : sans clé de pilotage,
  une balise <img> sur une page tierce suffisait à couper le moteur.
  405 + Allow: POST.
- whisperShutdown prend wsrvMu, que whisperEnsure garde jusqu'à deux
  minutes pendant un chargement de modèle : le geste dépassait le délai
  du navigateur, moteur pourtant déjà arrêté. whisperShutdownVite tue le
  processus en train de démarrer (poignée atomique hors verrou) au lieu
  d'attendre derrière lui.
- Sous systemd, une unité en crash-loop répond « activating », pas
  « active » : le stop était sauté et systemd relançait llama-server
  toutes les trois secondes pendant que l'UI disait « déjà arrêté ».
  engineNeedsStop élargit aux états transitoires.
- Un moteur planté au chargement était présenté comme « modèle
  déchargé — recharge-le » : LOAD_ERROR distingue les deux, le conseil
  renvoie vers l'erreur affichée dans le moniteur.
- Deux clics concurrents (moniteur + réglages) lançaient un stop au
  milieu d'un start ; VRAM_BUSY fait verrou, et le bouton se repeint
  depuis l'état renvoyé par le serveur, pas depuis l'ancien poll.
  Quelques Mo de bruit entre deux lectures ne font plus « VRAM libérée :
  0.0 Gio ».

Au passage, le 404 d'un téléchargement nomme le fichier manquant : sur
un dépôt qui publie six fragments sur sept (table PLE livrée à part),
« la révision a pu être réécrite » envoyait chercher au mauvais endroit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01961iDyM6pwn2dE2SW23gYX
2026-08-29 17:53:14 +02:00
MichaelandClaude Opus 5 009b585e75 VRAM : un bouton pour décharger le modèle et rendre la carte
Le modèle occupe la mémoire vidéo tant que le moteur tourne : une autre
application qui réclame la carte (jeu, encodage, autre serveur d'inférence)
ne trouvait plus rien à prendre. Le geste existait — « arrêter » le service,
au fond des réglages — mais son nom ne disait pas qu'il libérait la VRAM, et
il laissait tourner le serveur de dictée, qui garde la carte lui aussi.

- POST /api/vram/unload : arrête le moteur ET la dictée, attend que le pilote
  rende la mémoire (une lecture immédiate rapporte « 0 Mo libérés » après un
  déchargement pourtant réussi) et renvoie le bilan chiffré. Refuse pendant une
  génération, sauf {force:true} — la couper perdrait la réponse en cours.
- POST /api/vram/reload : relance le moteur, préflight compris (BIN/MODEL
  absents = la vraie raison tout de suite, pas un « chargement… » sans fin).
- Bouton sur les jauges du moniteur, là où l'on regarde la VRAM ; il devient
  « Recharger le modèle » dès que le moteur est arrêté, d'après /api/status et
  non d'un drapeau local (un second onglet afficherait sinon un bouton qui ment).
- Même commande dans Réglages → Moteur → Service du moteur.
- Carte de saisie : « Modèle déchargé — Recharger le modèle » au lieu de
  « Le modèle charge », qui promettait un chargement qui ne viendrait jamais.

La lecture nvidia-smi de /api/vram passe dans web_vram.go (gpuStats), partagée
avec le bilan du déchargement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8n9tDn5sZudAcUT8U6uGg
2026-08-26 20:18:09 +00:00
Claude 895aa67366 Presets : le bouton « + » ne s'affichait jamais
.preset-add est en display:none par défaut, et n'était révélé que par
« details[open]>summary>.preset-add ». Les panneaux de réglages ont
migré vers <section class="set-pane"><div class="set-pane-h">, donc ce
sélecteur ne matchait plus : le « + » des Presets restait invisible et
il n'y avait plus aucun moyen d'en créer un depuis l'interface.

Le même bouton dans « Discussions » est resté visible, lui, parce qu'il
vit encore dans un <details open><summary> — d'où un défaut qui ne
touchait que les presets.

Règle ajoutée pour le nouveau conteneur, puis UI réassemblée
(tools/assemble-ui).

Vérifié dans l'application lancée : display passe de none à block, et le
« + » apparaît dans l'en-tête du volet Presets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
2026-08-23 12:56:17 +00:00
LogiFlow 08eaf10a34 Merge pull request #23 from R0m1k3/fix/arg-scope-dockerfile
Image : l'ARG des drapeaux whisper était hors de portée — cmake tournait à nu
2026-08-22 04:16:09 +02:00
MichaelandClaude Fable 5 e177d54e39 Image : l'ARG des drapeaux whisper était hors de portée — cmake tournait à nu
Le build de 51 minutes a échoué au lien final : « libcuda.so.1 not found »,
références cuMem* non résolues, et une libggml-cuda.so PARTAGÉE alors que
BUILD_SHARED_LIBS=OFF est censé être passé. Ce dernier détail était le vrai
indice : les drapeaux n'atteignaient pas cmake du tout.

ARG WHISPER_CMAKE_FLAGS="…" était déclaré entre deux étapes. Règle Docker :
un ARG posé après un FROM appartient à l'étape où il apparaît ; les « ARG »
nus des étapes whisperbuild-* héritent, eux, du scope GLOBAL — où rien
n'était défini. Valeur vide, cmake sans aucun drapeau : ggml en bibliothèques
partagées (échec de lien contre les stubs du pilote), et surtout
-march=native — le SIGILL de la PR #18 revenu en silence sur les deux
binaires. Les tests du Dockerfile n'y voyaient rien : ils vérifiaient le
texte, pas les règles de portée de Docker.

- L'ARG remonte avant le premier FROM, à côté de LLAMACPP_IMAGE.
- Chaque étape vérifie désormais SON binaire : un ldd qui montre libggml ou
  libwhisper en dynamique fait échouer le build sur-le-champ, au lieu de
  laisser partir un binaire qui ne trouvera pas ses .so dans l'image finale.
- TestDockerfileArgFlagsGlobal verrouille la position de l'ARG, ancré en
  début de ligne — une première version se laissait berner par une
  occurrence en commentaire, sa contre-épreuve l'a montré.

libcuda.so.1 reste une dépendance dynamique normale du binaire CUDA : c'est
le pilote, injecté à l'exécution par le NVIDIA Container Toolkit, et
l'édition de liens la résout via les stubs du toolkit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-22 04:12:52 +02:00
LogiFlow 66b53c8255 Merge pull request #22 from R0m1k3/fix/workflow-yaml-double-cle
Workflow : clé YAML dupliquée, plus aucun build ne partait
2026-08-21 23:07:17 +02:00
MichaelandClaude Fable 5 3991e68fc9 Workflow : clé YAML dupliquée, plus aucun build ne partait
Depuis la fusion de la PR #21, chaque push sur main échouait en moins d'une
minute avec zéro job lancé : le YAML du workflow était devenu invalide.

La PR #21 ajoutait cache-from/cache-to après build-args, alors que le bloc
with: les portait DÉJÀ en fin de liste. Une clé dupliquée dans un mapping
YAML fait rejeter le workflow avant même son démarrage — d'où des échecs
immédiats, sans le moindre journal.

Rectification au passage : le cache n'était pas absent, il était déjà actif.
Les trente-sept minutes venaient de la nouveauté de l'étape CUDA (cache
froid) et de l'absence de borne d'architectures, corrigée elle pour de bon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 22:36:37 +02:00
LogiFlow cc218ccfd7 Merge pull request #21 from R0m1k3/fix/cuda-blackwell-et-cache
Image : CUDA 12.8 pour Blackwell, architectures bornées, cache des couches
2026-08-21 22:33:06 +02:00
MichaelandClaude Opus 5 d8ed86bbba Image : CUDA 12.8 pour Blackwell, architectures bornées, cache des couches
Le build ne mourait plus, mais tournait encore après trente-sept minutes. Et
il aurait produit un binaire inutilisable sur la moitié du matériel visé.

Les GPU de la machine cible sont une RTX 5060 Ti (Blackwell, sm_120) et une
RTX 3060 (Ampere, sm_86). L'étape de compilation était épinglée sur
nvidia/cuda:12.4.1 : ce nvcc-là ne CONNAÎT pas Blackwell. Il refuse
l'architecture, et le binaire ne tournerait au mieux que par recompilation PTX
au chargement. 12.8.1 sur Ubuntu 24.04 est désormais utilisé — exactement ce
avec quoi llama.cpp bâtit l'image amont qui fournit libcudart.

Pour la durée, deux causes distinctes :

- Sans borne, ggml compile pour TOUTES les architectures qu'il connaît, de
  Maxwell à Blackwell. CMAKE_CUDA_ARCHITECTURES ramène le travail à quatre
  (Turing → Blackwell), et l'ARG CUDA_ARCHS permet d'élargir sans toucher au
  fichier pour un GPU plus ancien.
- Surtout : ce binaire ne dépend d'AUCUN fichier du dépôt, et il était pourtant
  recompilé à chaque push. Le cache de couches GitHub Actions le réutilise tant
  que ses lignes du Dockerfile ne bougent pas ; seuls le code Go et
  l'assemblage de l'image se refont.

Un test vérifie le plancher CUDA et la présence de la borne d'architectures :
ces deux fautes ne se voient pas à la lecture et coûtent quarante minutes à
découvrir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 22:25:53 +02:00
LogiFlow 748328db25 Merge pull request #20 from R0m1k3/fix/build-cuda-oom
Image : le build CUDA était tué par l'OOM, pas en échec de compilation
2026-08-21 21:44:47 +02:00
MichaelandClaude Opus 5 8e88a73d91 Image : le build CUDA était tué par l'OOM, pas en échec de compilation
Le premier build de l'image avec whisper CUDA a échoué après huit minutes
sans écrire une seule ligne d'erreur : le journal s'arrête à 92 % de la
compilation, puis « Complete job ». Un compilateur qui échoue dit pourquoi ;
un compilateur tué ne dit rien.

Ces 92 % étaient atteints en TREIZE secondes. Ce ne sont pas des compilations
terminées, ce sont des nvcc lancés simultanément.

« cmake --build -j » sans nombre autorise chez Make un parallélisme illimité.
ggml-cuda compte environ 200 fichiers d'instanciation de gabarits et chaque
nvcc réclame 1 à 2 Go : le runner (16 Go) mourait d'un OOM. L'étape CPU y
survivait — peu de fichiers, compilation légère — ce qui a rendu le piège
invisible jusqu'à l'arrivée de CUDA.

- -j"$(nproc)" sur les deux étapes, et un test le vérifie désormais : cette
  faute est indétectable à la lecture et ne se manifeste qu'en CI.
- Le runner libère dotnet, android et ghc avant de bâtir. L'empilement
  nvidia/cuda-devel + runtime CUDA + Chromium approche les 14 Go disponibles ;
  cette part-là reste une hypothèse, mais elle coûte une minute à écarter.
- whisper-server CUDA est strippé : les symboles de débogage d'un binaire
  ggml-cuda pèsent plusieurs centaines de mégaoctets dans l'image finale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:35:42 +02:00
LogiFlow f4969de9f7 Merge pull request #19 from R0m1k3/feat/dictee-temps-reel
Dictée : whisper-server supervisé, modèle et GPU au choix
2026-08-21 21:22:18 +02:00
MichaelandClaude Opus 5 5d391c4f73 Dictée : panneau de réglages — GPU, modèle, langue, test du micro
Le choix du matériel et du modèle n'existait nulle part : la dictée prenait
le seul modèle codé en dur et le seul chemin possible. Sur une machine à
plusieurs GPU, rien ne permettait de dire lequel prêter à whisper.

Le panneau liste les GPU avec leur VRAM libre (/api/vram donne déjà tout), et
chaque modèle avec sa taille de téléchargement et sa mémoire. Un modèle absent
le dit dans sa propre étiquette : le choisir n'est pas un réglage instantané
mais un transfert de plusieurs centaines de mégaoctets, et le taire ferait
passer l'attente pour une panne.

Le test du micro capte trois secondes, affiche le niveau mesuré puis la
transcription, sans toucher au champ de saisie. Vu de l'extérieur, un micro
muet, un niveau trop faible et un moteur en panne se ressemblent tous — il
fallait bricoler ce diagnostic à la main pour les distinguer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:11:21 +02:00
MichaelandClaude Opus 5 3cd4facb0e Dictée : whisper-server supervisé, modèle chargé une seule fois
Chaque dictée relançait whisper-cli, qui relisait le modèle depuis le disque
avant de transcrire. Ce chargement dominait le temps de réponse — 3,2 s de
calcul pour 3,4 s d'audio — et aucun réglage ne pouvait le rattraper.

whisper-server garde le modèle en mémoire entre deux phrases. Loki le
supervise : démarrage au premier clic sur le micro, extinction après dix
minutes sans dictée. Une dictée n'est pas un service permanent, et garder un
modèle chargé toute la journée priverait le moteur de chat de sa VRAM.

- Réglages serveur (modèle, langue, matériel, réactivité) dans bkState, avec
  leurs routes. La langue par défaut passe de « auto » à « fr » : sur quelques
  secondes d'audio la détection se trompe, et une langue mal détectée produit
  du charabia — des suites de caractères géorgiens ont été observées.
- Catalogue de quatre modèles, de small à large-v3 ; large-v3-turbo par
  défaut. Téléchargement par identifiant, dans un .part renommé à la fin : un
  transfert interrompu ne laisse plus un .bin tronqué qu'on croirait bon.
- Le GPU se choisit par CUDA_VISIBLE_DEVICES, posé en REMPLAÇANT toute valeur
  héritée. Dupliquée, la variable laisse le gagnant dépendre de la libc.
- Les marqueurs de whisper ([BLANK_AUDIO], (silence)) ne sont plus collés dans
  le champ de saisie comme s'ils étaient du texte dicté.

L'image construit DEUX binaires whisper-server, CPU et CUDA. LLAMACPP_IMAGE
accepte la variante CPU de l'image amont ; sur cette base les .so CUDA sont
absentes et un binaire lié à CUDA n'a même pas de quoi démarrer, donc aucun
repli n'est possible depuis le programme. Le garde-fou du Dockerfile vérifie
maintenant les deux étapes : les drapeaux de portabilité de la PR #18 doivent
survivre à l'arrivée de CUDA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:08:12 +02:00
MichaelandClaude Opus 5 3624194b39 Plan : moteur de dictée supervisé et configurable
Sept tâches, de la persistance des réglages au panneau de l'interface. Le
temps réel (WebSocket + découpeur) fait l'objet d'un second plan : ce plan-ci
livre déjà un logiciel utilisable — bon modèle, GPU au choix, lenteur
supprimée — sans le direct.

Contraintes relevées dans le dépôt et inscrites en tête du plan : la CI
compile pour Windows (donc pas de Setsid), staticcheck doit rester à zéro, et
index.html est généré par tools/assemble-ui, jamais édité à la main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:00:43 +02:00
MichaelandClaude Opus 5 2fa05fdaf7 Conception : dictée en temps réel, configurable selon le matériel
La dictée marche depuis la PR #18 mais comprend mal et n'écrit qu'à l'arrêt du
micro. La lenteur est structurelle : chaque requête relance whisper-cli, qui
recharge le modèle depuis le disque. Découper en tranches par-dessus ce
mécanisme reviendrait à recharger le modèle toutes les quelques secondes.

La conception retient whisper-server (modèle chargé une fois, supervisé par
Loki comme llama-server), un découpage en tranches calé sur les silences et
piloté côté Go, et un panneau Dictée pour choisir GPU, modèle, langue et
réactivité.

Deux points tranchés en cours de route :

- Deux binaires whisper-server, CPU et CUDA, pas un seul. LLAMACPP_IMAGE
  accepte la variante CPU de l'image amont ; sur cette base les .so CUDA sont
  absentes et un binaire lié à CUDA n'a même pas de quoi démarrer. Un binaire
  unique condamnerait cette variante.
- Le seuil de silence est adaptatif. Un seuil fixe ne coupe jamais dans une
  pièce calme avec un micro discret, et coupe sans arrêt près d'un ventilateur.

Les drapeaux GGML_NATIVE=OFF restent exigés sur les deux cibles : le SIGILL de
la PR #18 ne doit pas revenir par la porte du build CUDA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 17:18:49 +02:00
LogiFlow e0ed0a32af Merge pull request #18 from R0m1k3/fix/whisper-binaire-non-portable
Dictée : whisper-cli compilé pour la machine de build, pas pour la tienne
2026-08-21 16:30:48 +02:00
MichaelandClaude Opus 5 a19b490b6a Dictée : whisper-cli compilé pour la machine de build, pas pour la tienne
La dictée n'a jamais transcrit une seule phrase. Le micro enregistrait bien —
contexte HTTPS, autorisation accordée, bouton rouge — mais le serveur répondait
500 sur chaque envoi :

    whisper-cli : AMX is not ready to be used!
    read_audio_data: reading audio data from '/tmp/loki-dictee-...wav' ...
    read_audio_data: trying to decode with miniaudio

Deux indices dans ces trois lignes. « AMX is not ready to be used » ne sort que
d'un binaire COMPILÉ avec les instructions AMX, celles d'un Xeon récent — le
runner GitHub. Et le journal s'arrête net : ni « failed to decode », ni texte
transcrit. Le processus ne renvoie pas une erreur, il meurt.

ggml compile en -march=native par défaut. Le binaire partait donc taillé pour
le processeur qui l'avait construit, et rencontrait une instruction illégale
dès qu'on le posait ailleurs.

- GGML_NATIVE=OFF à l'étape whisperbuild, AVX-512 et AMX explicitement coupés.
  Reste la ligne de base AVX2/FMA/F16C de ggml, présente sur tout x86-64 depuis
  2013. Un test lit le Dockerfile et garde ces drapeaux : rien à l'exécution ne
  rappellerait leur raison d'être au prochain qui touchera cette étape.
- Le handler ne renvoyait que la dernière ligne de stderr. Sur une mort par
  signal, cette ligne est celle d'AVANT le coup fatal : elle ressemble à une
  explication sans en être une, et envoie chercher du côté de l'audio. Il dit
  maintenant comment le processus a fini — « signal: illegal instruction » vaut
  diagnostic à lui seul.

L'image doit être reconstruite : whisper-cli y est compilé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 16:27:46 +02:00
LogiFlow 393986faa6 Merge pull request #17 from R0m1k3/fix/ngl-sentinelle-999-auto
NGL=999 : la sentinelle « toutes les couches » devient -ngl auto
2026-08-21 10:45:26 +02:00
MichaelandClaude Opus 5 2585d2c2e1 NGL=999 : la sentinelle « toutes les couches » devient -ngl auto
Le correctif précédent ne pouvait pas s'appliquer. Il ne posait « -ngl auto »
que si la clé NGL était ABSENTE de config.env — or defaultConfig() y sème
NGL=999 sur chaque installation neuve. La quasi-totalité des configurations la
portent donc sans que personne ne l'ait choisie, le code la lisait comme un
choix délibéré, et l'abandon revenait intact :

  W common_fit_params: failed to fit params to free device memory:
    n_gpu_layers already set by user to 999, abort

999 n'a jamais été un nombre de couches : c'est la sentinelle historique
« toutes ». Sur un moteur qui sait mesurer la VRAM libre, elle devient donc
« -ngl auto », et Loki le dit sur stderr plutôt que de le faire en douce. Tout
autre nombre reste intouché — c'est un vrai choix. NGL=all force l'ancien
comportement, NGL=auto n'envoie toujours aucun drapeau, et un moteur ancien
reçoit toujours 999 (l'omettre le ferait tourner 100 % CPU).

- La décision sort dans nglArgs(), fonction pure : la seule question qui
  demande le moteur arrive déjà tranchée, donc elle se teste sans lancer
  llama-server. 10 cas couverts.
- binFitsLayersItself() double la lecture fine de l'aide par la présence de
  --load-mode. Les deux sont arrivés dans la même vague ; une description
  reformulée ou une colonne plus large ne doit pas faire conclure « moteur
  incapable » et réimposer 999.
- L'aide de la clé et le sous-titre du champ disent la nouvelle règle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 09:47:32 +02:00
LogiFlow fedbaf32a0 Merge pull request #16 from R0m1k3/fix/llamacpp-deprecated-flags-dictee
Moteur : drapeaux de chargement à jour, -ngl auto ; dictée qui dit po…
2026-08-21 09:26:46 +02:00
MichaelandClaude Opus 5 cbf8635c61 Moteur : drapeaux de chargement à jour, -ngl auto ; dictée qui dit pourquoi
Les llama-server récents ont fusionné --mlock, --mmap et --no-mmap dans un
seul --load-mode, et refusent désormais d'ajuster les couches GPU dès qu'on
leur impose un nombre : « n_gpu_layers already set by user to 999, abort ».
Le moteur pousse alors tout sur le GPU et meurt en cudaMalloc, ou se replie
à moitié sur le CPU — débit effondré, GPU à 100 %.

- Traduction des drapeaux dépréciés AU LANCEMENT, pas dans le preset :
  --mlock → --load-mode mlock, --no-mmap → --load-mode none, --mmap →
  --load-mode mmap. Les interrupteurs de l'interface continuent d'écrire
  l'ancienne forme, et un moteur antérieur la reçoit telle quelle. Un
  --load-mode écrit à la main dans EXTRA_ARGS gagne sur tout.
- -ngl auto par défaut quand le moteur propose la valeur, 999 sinon (l'omettre
  sur un moteur ancien le ferait tourner 100 % CPU). NGL=<nombre> dans le
  preset reste souverain.
- --parallel et -ngl ne sont plus ajoutés en double quand EXTRA_ARGS les porte
  déjà : le moteur râlait (« specified multiple times ») et, pire, c'est la
  dernière occurrence qui gagne — le réglage explicite du preset était donc
  silencieusement écrasé par le défaut de Loki.
- Les capacités se lisent dans « <bin> --help », une seule fois par binaire
  (binHelp met en cache) au lieu d'un lancement par question.

Dictée : quand le téléchargement du modèle whisper échoue (conteneur sans
réseau, Hugging Face injoignable), le serveur renvoyait bien la raison mais
l'interface affichait quand même « téléchargement (0 %) ». L'erreur cachée
laissait cliquer indéfiniment sur un micro qui n'écrira jamais rien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 09:26:22 +02:00
MichaelandClaude Fable 5 69bf8d985d Vérification du moteur de retour ; boutons MCP au gabarit ; « Paramètres »
- Le bouton « vérifier les mises à jour » revient dans la carte Moteur : il
  ne fait QUE regarder, là où « mettre à jour » télécharge et bascule après
  confirmation. Deux gestes qui n'engagent pas pareil, deux boutons.
- Les boutons « catalogue » / « + ajouter un serveur » (MCP) étaient des
  pilules arrondies au milieu de boutons rectangulaires : même gabarit que
  leurs voisins (4 px, 12 px, mêmes marges).
- « Réglages » devient « Paramètres » dans la barre latérale et en tête de
  la modale.

Version 0.12.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 08:13:21 +02:00
MichaelandClaude Fable 5 50fccdc9ad Captures d'écran : visibles en entier, jamais repliées d'office
La carte d'outil qui porte une capture (web_screenshot) échappe au plafond
de 280 px — l'image se montrait par le trou d'une serrure — et au repli
automatique : l'image EST le résultat, la replier la cachait sitôt prise.
Elle garde son max-height propre (420 px) et s'ouvre plein écran au clic.
Retirée de la liste de repli du tour pour survivre aussi au repli de fin de
tour. Au rejeu du journal, elle arrive repliée comme le reste de
l'historique. Version 0.12.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 22:58:29 +02:00
MichaelandClaude Fable 5 66072e4047 Cartes de section : ouvertes tant qu'elles travaillent, repliées sitôt finies
Une carte (mémoire, raisonnement, outil…) reste ouverte pendant que SA
section travaille — bornée à 280 px, défilement qui suit l'écriture — et se
replie dès qu'elle est finie : l'outil au retour de son résultat, le
raisonnement quand le bloc suivant démarre, le reste en fin de tour. On
retrouve les fenêtres distinctes par section, sans qu'elles envahissent la
page. Version 0.12.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 22:47:45 +02:00
MichaelandClaude Fable 5 62573ff972 Version 0.12.0
Sélecteur de modèle fonctionnel, dictée vocale, cartes de raisonnement qui
suivent l'écriture, moyenne tok/s de la conversation, % de chargement réparé
à chaud, tri des discussions par création, bouton Réglages en pied de barre.
Notes de version à jour ; .syso régénérés.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 22:30:58 +02:00
MichaelandClaude Fable 5 481028b023 Cartes raisonnement qui suivent l'écriture ; micro aligné ; moyenne tok/s
- Le défilement interne des cartes bornées ne suivait PAS la génération :
  le rendu en attente porte la bulle entière (.msg), pas .body — le test
  « parent = bodywrap » ne passait jamais et l'épinglage bas était mort.
  On cherche maintenant le bodywrap DANS la bulle : la carte suit le texte
  au fur et à mesure, et une remontée manuelle est respectée. Les cartes
  d'outil suivent aussi la ligne en cours, puis remontent en tête une fois
  l'appel terminé.

- Bouton micro : il gardait la bordure des boutons génériques, décalé de
  ses voisins. Ajouté aux règles communes des boutons de saisie (34 px,
  sans bordure, fond au survol) avec #attach et #files-btn.

- La carte du temps total (au-dessus de la saisie) affiche la vitesse
  moyenne de la conversation : « en cours — 12 s · moy 21.3 tok/s ».
  Alimentée par les événements stats journalisés (gen_tokens/gen_ms,
  cumulatifs par complétion — seul le delta est ajouté), donc rejouée au
  chargement : la moyenne survit au refresh. Remise à zéro au reset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 22:24:07 +02:00
MichaelandClaude Fable 5 86d711e772 Sélecteur de modèle qui charge vraiment ; % de chargement qui bouge
Quatre retouches d'interface :

- Le sélecteur de l'en-tête liste maintenant les MODÈLES du disque (groupe
  « Modèles (.gguf) ») en plus des presets : en choisir un le charge —
  POST /api/models/use écrit MODEL seul (contexte, NGL et échantillonnage
  conservés) et redémarre le service en arrière-plan. Sans preset créé, le
  sélecteur n'offrait aucun choix.

- Pourcentage de chargement : sur un redémarrage à chaud, le modèle est déjà
  dans le cache disque — llama-server ne lit rien (read_bytes reste à 0) et
  la pastille passait de « 0 % » à « prêt » sans jamais monter. On prend le
  plus avancé de read_bytes et de la mémoire résidente (VmRSS), qui grandit
  cache ou pas.

- Discussions triées par date de CRÉATION (récentes en tête) : une
  discussion garde sa place, écrire dans un vieux fil ne le fait plus
  remonter.

- Bouton Réglages ancré en pied de barre latérale, juste au-dessus du
  moniteur Performance — toujours au même endroit, quel que soit le nombre
  de discussions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 22:13:16 +02:00
MichaelandClaude Fable 5 ad107ed284 Dictée vocale : micro dans la carte de saisie, whisper.cpp local
Un bouton micro dans la carte de saisie : un clic enregistre, un second
arrête, transcrit et pose le texte dans le champ sans écraser ce qui s'y
trouve. Anneau rouge pulsé pendant l'enregistrement, garde-fou à 90 s.

La transcription est 100 % locale : POST /api/transcribe → whisper-cli
(whisper.cpp, compilé CPU en statique dans une étape dédiée du Dockerfile).
Le modèle ggml-small-q5_1 (~190 Mo, multilingue) n'est pas dans l'image :
téléchargé au premier usage dans /data/whisper/ avec progression (503
{downloading, pct} en attendant), il survit aux recréations du conteneur.

L'audio est encodé en WAV 16 kHz mono côté navigateur (whisper.cpp ne lit
que du PCM) — pas de ffmpeg dans l'image. Le micro n'existe qu'en contexte
sécurisé (HTTPS ou localhost) : le bouton l'explique au lieu d'échouer en
silence.

Vérifié bout en bout avec le binaire whisper.cpp officiel : téléchargement
du modèle par le handler, puis une sinusoïde 440 Hz transcrite « (beeping) ».

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 19:52:38 +02:00
MichaelandClaude Fable 5 43489769f4 Cartes raisonnement bornées et fluides ; version 0.11.0
Un long raisonnement (des milliers de tokens) faisait grandir la page de
plusieurs écrans et finissait par figer l'affichage : le bloc entier était
re-parsé en Markdown à chaque tick, en O(n²). Deux mesures :

- Les cartes raisonnement/outils ont une hauteur bornée (280 px) avec
  défilement interne, collé en bas pendant la génération (un défilement
  manuel vers le haut est respecté).
- En direct, un bloc de raisonnement géant n'est re-parsé que sur sa fin
  (REASON_TAIL) ; le texte complet est posé au rendu de fin de bloc. La
  finalité voyage avec le rendu en attente (renderPending.final) : le timer
  déjà armé passait final=false et consommait le rendu final en ne posant que
  la queue — le début du raisonnement n'était jamais rendu.

Version 0.11.0 (const, versioninfo, .syso régénérés) ; README et
RELEASE_NOTES à jour sur les nouveautés du fork.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:16:55 +02:00
MichaelandClaude Fable 5 a333603ca6 Réglages en modale à deux volets ; discussions triées par activité
La barre latérale ne garde que les discussions et le moniteur machine : tout
le bloc « Réglages » (11 sections empilées en details) devient une modale
avec sa nav de sections à gauche et LE panneau choisi à droite — chaque
section a enfin toute la largeur, et la barre latérale respire.

Les panneaux réutilisent tels quels les gabarits existants (.slist,
.switchrow, .subhead) ; les ids historiques (svc-log-box, lc-details,
tasks-details…) sont conservés sur les panneaux, et openDetails() les résout
vers openSettings(panneau) : pastille d'état du moteur, pastille
d'installation et jobs llama.cpp rouvrent la modale au bon endroit sans
changer leurs appelants. Le chargement paresseux du journal du moteur
(ancien ontoggle) devient un hook d'affichage du panneau.

Discussions : la plus récente en tête. Le serveur triait déjà par date
d'activité, mais son tri stable laissait une discussion toute neuve SOUS
celle qu'on venait de quitter (touchées dans la même seconde) : l'UI
départage par date de création puis par ordre d'index.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:05:38 +02:00
Michael f4090059f1 Merge branch 'claude/model-badge-reponses' : nom du modèle sur chaque réponse 2026-08-20 15:58:57 +02:00
MichaelandClaude Fable 5 b814862f40 Nom du modèle affiché à côté de « Loki » sur chaque réponse
Le nom du modèle (fichier .gguf, sans dossier ni extension) est journalisé
avec la borne de tour côté serveur : chaque réponse garde le modèle qui l'a
réellement produite, y compris après un rechargement de page (rejeu du
journal) et même si un autre modèle est chargé depuis.

Côté UI, une pastille discrète s'affiche dans l'étiquette de la bulle de
réponse. Elle vit dans dataset.model pour survivre au repeint des libellés
lors d'un changement d'identité (applyIdentity).

Vérifié en conditions réelles (moteur simulé, rechargement en pleine
génération) : badge, tok/s des bulles et chrono « en cours » sont bien
rejoués après un refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 15:50:51 +02:00
LogiFlow 88dbe5642f Merge pull request #14 from R0m1k3/claude/new-session-hrmmyc
Support Hugging Face gated repositories with configurable tokens
2026-08-20 12:38:03 +02:00
Claude 7f5765160d Jeton Hugging Face réglable dans l'interface
Le jeton ne vivait que dans la variable d'environnement HF_TOKEN : découvrir
depuis l'interface qu'un dépôt est verrouillé, c'était devoir éditer un
docker-compose et recréer le conteneur pour y répondre.

- Éditeur de preset → Modèle → « Jeton Hugging Face » : ligne repliée comme
  « Dossiers de modèles », qui affiche l'état (aucun / masqué / fourni par
  l'environnement). Le bandeau d'un dépôt verrouillé y mène d'un clic.
- Le jeton est VÉRIFIÉ auprès de /api/whoami-v2 avant d'être enregistré (le
  compte s'affiche) : un jeton mal collé accepté en silence rendrait le 401
  qu'on cherchait à expliquer. Il est rangé avec les secrets en base d'état, pas
  dans config.env que le changement de preset réécrit en bloc, et n'est jamais
  renvoyé en clair — seulement masqué.
- Priorité : jeton enregistré, puis HF_TOKEN. Rien d'enregistré = comportement
  d'avant à l'identique. L'enregistrement vide le cache des réponses obtenues
  sans jeton.
- Le jeton n'est envoyé qu'aux adresses Hugging Face : un lien collé vers un
  autre hébergeur n'a aucune raison de recevoir un secret.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hz1QWmvZqW3t53YC5SJBKC
2026-08-20 10:32:44 +00:00
Claude b339cced0a Dépôts Hugging Face verrouillés : dit lesquels, et pourquoi le 401
Un dépôt « gated » (orcarouter/Qwen3.8-27B-Uncensored-GGUF, vécu) laisse lire
son arborescence sans rien : Loki listait donc ses seize quantifications avec
leur verdict mémoire, puis échouait sur « HTTP 401 depuis la source » au
premier octet. Le message ne disait ni que le dépôt était verrouillé, ni qu'il
fallait accepter ses conditions, ni où poser un jeton.

- Le refus est maintenant traduit à partir de X-Error-Code (GatedRepo,
  RepoNotFound, EntryNotFound…) et nomme le dépôt, l'action à faire et l'état
  du jeton : absent (il en faut un) ou présent mais sans accès. 404, 416, 429
  et les pannes de la source y gagnent aussi une phrase utile.
- Le verrou se voit AVANT de choisir une quantification : la recherche demande
  `expand[]=gated` et la fiche du dépôt est lue à l'ouverture, d'où une
  pastille « accès restreint » dans la liste et un avertissement en toutes
  lettres au-dessus des fichiers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hz1QWmvZqW3t53YC5SJBKC
2026-08-20 10:18:55 +00:00
LogiFlow d7214d97eb Merge pull request #13 from R0m1k3/claude/slot-timing-performance-jqwjgx
Synchronisation avec l'amont AJEAN + tâches planifiées
2026-08-20 11:35:10 +02:00
Claude 0f58ad7b49 Tâches planifiées : l'IA travaille toute seule (repris de l'amont AJEAN)
Portage des v0.9.9 → v0.10.2 de l'amont, la dernière vraie fonctionnalité qui
nous manquait. Une consigne, une fréquence (« @every 2h », « tous les jours à
9h », ou une expression cron à 5 champs, dans le fuseau du navigateur), et l'IA
l'exécute seule en arrière-plan. Preset épinglé par tâche (bascule de modèle
avant l'exécution, attente du rechargement), accès mémoire et web réglables,
interrupteur maître pour tout suspendre, bouton « tester maintenant ».

Le planificateur est une goroutine, un tic par minute, dans le process qui
détient la conversation et parle au moteur. Une seule tâche part par tic : de
toute façon une seule inférence tourne à la fois, et étaler les départs évite
qu'une rafale monopolise le modèle. Occupé ou modèle en cours de chargement
n'est pas un échec — la tâche repasse au tic suivant.

Une tâche ne partage QU'UN point avec le chat : le verrou de génération. Ni
messages, ni journal d'affichage, ni epoch — elle construit son fil éphémère et
le jette, ne gardant que son texte final comme compte-rendu (borné à 4000
caractères, réinjecté au passage suivant pour la continuité).

Deux adaptations, parce que loki n'est pas l'amont :

— Dossier de travail. Ici il appartient à la DISCUSSION ouverte : une tâche y
  aurait déposé ses fichiers, et en aurait changé en cours de route si
  l'utilisateur changeait de discussion — pour disparaître avec elle à la
  suppression. Chaque tâche a donc le sien (workspace/tasks/<id>/), stable d'un
  passage à l'autre. La bascule ne touche que les points d'entrée des OUTILS
  (agentCwd) : le panneau Fichiers, les dépôts et les liens des messages
  continuent de suivre la discussion de l'utilisateur.

— Capacités. Mem est posé explicitement à MemOff quand l'agent est coupé : le
  zéro de MemMode est la chaîne vide, qu'EnabledTools ne reconnaît pas comme
  « coupée » — une tâche sans agent se serait vu offrir les outils mem_*. Le
  mode code reste off : rôles, critères et passe de vérification n'ont pas de
  sens sans personne en face.

Le refus d'un message pendant qu'une tâche tourne dit maintenant LAQUELLE occupe
le modèle : « génération en cours » sur un fil vide et immobile n'expliquait
rien.

Interface : section repliable dans les réglages (liste, état, prochain passage,
pastille), modale d'édition bâtie sur le gabarit de l'éditeur de preset, panneau
« dernier résultat » en markdown. Vérifié dans un vrai navigateur — création,
rendu, réouverture en édition, bascule intervalle/cron, interrupteur maître,
suppression — sans une seule erreur JS.
2026-08-20 09:31:59 +00:00
Claude 03ae361ade Synchronisation avec l'amont AJEAN (v0.9.5 → v0.10.7)
Le fork est parti de la v0.9.4 ; l'amont en est à la v0.10.7. Reprise de ce
qui manque VRAIMENT ici, en laissant de côté ce que loki a déjà résolu à sa
façon (contexte MTP via --parallel 1, jauge de contexte, chrono de tour,
vignettes d'images, ligne d'état de génération).

Rendu du chat cadencé puis lissé (amont v0.9.5 issue #24, v0.10.5). Chaque
token re-parsait le Markdown du bloc ENTIER : du O(n²) qui faisait ramer
l'interface sur un long raisonnement — le moteur débitait toujours autant, mais
les tokens semblaient arriver au ralenti et un simple rafraîchissement
« réparait » tout. Le texte s'accumule désormais et n'est re-rendu qu'à
intervalle adaptatif (16 ms sur un petit bloc, jusqu'à 500 ms sur un énorme),
soldé à chaque frontière (outil, bascule de rôle, fin de tour, erreur, rejeu).
Par-dessus, un lissage d'apparition découple l'arrivée de l'affichage : le
décodage spéculatif rend les tokens par rafales, le texte sautait par paquets ;
il s'écoule maintenant à cadence régulière. Rejeu exclu — relire un fil ne doit
pas être une lente réécriture.

Échantillonnage réglable par preset (amont v0.9.5/v0.9.6) : TEMP, TOP_P, TOP_K,
MIN_P, PRESENCE_PENALTY, REPEAT_PENALTY, injectés dans chaque requête (donc sans
redémarrage du moteur), vide = défaut du serveur. Sans ça seule la température
voyageait et le reste retombait sur les défauts de llama.cpp, rarement ceux que
recommande le modèle. Différence avec l'amont : REASONING_EFFORT n'est PAS
traité là — loki lui réserve un chemin plus riche, et l'écrire ici écraserait
`chat_template_kwargs`, donc la consigne « aucune ».

Un seul message système, en tête, à l'envoi (amont v0.9.8, issue #26).
steerSystem ne couvrait que les consignes de loki ; un historique venu
d'ailleurs peut encore en porter deux, et Qwen3.x en --jinja répond alors
« System message must be at the beginning ». Copie normalisée : l'historique
affiché et persisté garde sa forme.

Détection Vulkan multi-distro (amont issues #28, #29) : le chemin Debian codé en
dur est invisible sur Fedora/RHEL/Atomic, où le plan de build retombait sur le
CPU. ldconfig d'abord, puis les chemins connus.

Dossier de travail (amont v0.10.2) : la consigne dit maintenant ce que le
dossier EST — l'endroit par défaut de tout ce que le modèle produit — et nomme
les dossiers système à ne pas toucher, au lieu d'interdire vaguement d'en sortir.

Non repris : les tâches planifiées (~1200 lignes + interface, à décider), et le
quoting cmd.exe par .bat temporaire (loki tourne en conteneur Linux).
2026-08-20 09:31:59 +00:00
LogiFlow 40f34170b9 Merge pull request #12 from R0m1k3/claude/slot-timing-performance-jqwjgx
Raisonnement : un gabarit qui refuse le niveau ne tue plus le tour
2026-08-20 10:52:12 +02:00
Claude f3b0f78b64 Raisonnement : un gabarit qui refuse le niveau ne tue plus le tour
Qwen3.8-27B valide `reasoning_effort` au lieu de l'ignorer : il connaît
xhigh/medium/low, pas « high » — le niveau que l'interface enregistre par
défaut. Chaque message partait donc en 500, avec une trace jinja affichée
en guise d'erreur, et le 500 tombait dans la branche « prompt trop long »
de runChat : loki compactait l'historique pour rien avant d'abandonner.

Le refus dit lui-même ce que le gabarit accepte. On le lit (llm_effort.go),
on traduit le niveau demandé vers le plus proche sur l'échelle
none/minimal/low/medium/high/xhigh — à égalité, le plus fort, dégrader en
silence étant pire que générer un peu plus longtemps — et on rejoue le tour,
historique intact. Sans liste annoncée, le champ est simplement retiré.
« aucune » n'est jamais traduite : c'est une coupure, portée par
`enable_thinking` que tous les gabarits comprennent.

La traduction est retenue par modèle : les messages suivants ne repaient pas
l'aller-retour. Le repli est tracé sur stderr, sinon l'intensité choisie dans
l'interface n'est pas celle qui part au moteur sans que rien ne le dise.

« maximale » (xhigh) rejoint la liste des niveaux proposés : aucun gabarit ne
les connaît toutes, et sans elle le maximum d'un Qwen3.8 restait hors
d'atteinte. Le repli couvre les gabarits qui la refusent.
2026-08-20 08:49:09 +00:00
MichaelandClaude Opus 5 6a77999aac Retire l'ancienne application Python/React du dépôt
Le commit précédent a versé backend/ et frontend/ dans le dépôt — 94 Mo
de node_modules et d'artefacts de build, restes du fork pré-Go qui
traînaient encore dans le répertoire de travail. Ils ne sont plus suivis,
et .gitignore les refuse désormais avec les caches Python et npm.

L'image Docker n'était pas concernée : le Dockerfile ne copie que cmd/,
internal/, tools/ et les fichiers de module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:29:40 +02:00
MichaelandClaude Opus 5 b405adea6c Jauge de contexte, espace disque sur partage agrégé, panneau Fichiers
Trois défauts sans rapport entre eux, tous visibles à l'écran.

Jauge de contexte bloquée à « 0 / N (0 %) » : le comptage exact vient de
usage.prompt_tokens (include_usage), qu'un llama-server récent a cessé de
renvoyer. On note désormais si l'usage est arrivé ; sinon la fin de tour
publie l'estimation qui pilote déjà la compaction — approximative, mais
jamais absente. Et la souscription envoie la valeur courante juste après
le rattrapage : une page rechargée affichait zéro sur une discussion
pourtant pleine, faute d'événement ctx_used dans la fenêtre rejouée.

Espace disque : « il ne reste que 11,9 Go » sur un partage Unraid qui en
a des centaines. /mnt/user est servi par shfs, un FUSE qui agrège
plusieurs disques — statfs y renvoie l'espace d'un seul, et ce chiffre
REFUSAIT l'installation du modèle. Les systèmes de fichiers qui
approximent (FUSE, overlay, NFS, CIFS, Ceph, Gluster) sont reconnus par
leur magie : leur chiffre reste affiché, précédé d'un « ~ », mais ne
bloque plus rien.

Panneau Fichiers : le panneau entier défilait, emportant vers le haut
l'en-tête, le fil d'Ariane et le pied (occupation disque, bouton .zip)
dès qu'il y avait quelques fichiers. Seule la liste défile maintenant ;
le pied reste en bas et l'état vide se centre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:28:53 +02:00
MichaelandClaude Opus 5 895ff92ed8 Composeur : bandeaux au-dessus de la carte, chrono du tour, anneau réparé
Trois défauts d'affichage, une cause commune pour deux d'entre eux.

Le panneau des critères et la puce « mode Code » étaient insérés AVANT
#composer, qui est en position:absolute : ils restaient dans le flux de
la page et passaient donc SOUS la carte de saisie. Ils vivent maintenant
DANS le composeur, alignés sur sa colonne, et les critères s'ouvrent
repliés (cinq lignes dépliées mangeaient la moitié de l'écran).

Chrono du tour complet au-dessus de la carte : le temps entre l'envoi du
message et le moment où l'on peut reparler — appels d'outils, passes de
vérification et compaction compris. Les durées portées par les cartes ne
mesurent que la génération de texte ; celle-ci répond à « ça a pris
combien de temps, en tout ? ». Il avance à la seconde puis se fige sur le
total.

Anneau d'activité de la liste des discussions : l'animation portait sur
le <svg>, dont l'origine de rotation dépend de transform-box — défaut
qui a changé au fil des versions, d'où un anneau figé. Elle porte
désormais sur le conteneur HTML, qui tourne partout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:16:28 +02:00
MichaelandClaude Opus 5 4e5bc44607 Captures d'écran : montrées dans la bulle, sans dépendre du modèle
La capture n'apparaissait dans le fil QUE si le modèle recopiait la ligne
markdown rendue par l'outil. Un petit modèle l'oublie, et l'utilisateur
ne voyait jamais l'image qu'il avait demandée — la capture existait
pourtant bien sur le disque.

L'événement d'outil porte désormais le chemin de l'image et l'interface
la rend directement dans la bulle web_screenshot (cliquable pour la
loupe, comme les autres images du fil). Ce que le modèle en écrit
ensuite reste possible, mais n'est plus la condition de l'affichage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:11:38 +02:00
MichaelandClaude Opus 5 0430aa2281 Mode code : la capture d'écran sans accès internet, bornée au local
Le modèle cherchait à vérifier visuellement son travail, ne trouvait pas
web_screenshot (offert seulement avec l'accès internet), en concluait
« pas de Chromium local » et partait installer puppeteer — alors qu'un
Chromium headless est dans l'image depuis toujours.

L'outil est donc offert aussi au mode code sans internet, mais borné aux
adresses locales (localhost, 127.0.0.1, ::1, *.localhost) : photographier
son propre serveur de dev est une vérification locale, pas une sortie sur
le web — l'interrupteur d'accès internet garde tout son sens. Le prompt
du builder dit explicitement que le navigateur est déjà là et qu'il ne
faut installer aucun paquet de navigateur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:08:41 +02:00
MichaelandClaude Opus 5 608e7f7038 Moteur : --parallel 1 explicite — la VRAM d'avant est de retour
Les llama-server récents ouvrent 4 slots par défaut : des tampons de
calcul GPU multipliés par quatre, pour un serveur qui ne sert qu'un
utilisateur. Résultat vécu après une mise à jour du moteur : une config
27B qui tournait très bien meurt en « cudaMalloc failed: out of
memory » en pleine génération, sans qu'aucun réglage n'ait changé. On
repasse explicitement à un slot — le comportement historique — et
PARALLEL=n reste disponible dans le preset pour qui veut du parallèle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 07:47:41 +02:00
MichaelandClaude Opus 5 cf6e19794d Journal moteur : un crash-loop s'affiche « erreur », plus « chargement »
Un llama-server qui plantait au démarrage (backtrace __libc_start_main
dans le journal) laissait la pastille sur « chargement 0 % » pour
l'éternité : aucun des motifs de modelLoadError ne reconnaissait un
crash. Ajoutés : backtrace (signal, segfault, abort), mémoire GPU
insuffisante (out of memory / cudaMalloc failed — avec le conseil de
réduire CTX), et erreur CUDA (GPU indisponible après un redémarrage du
serveur). Pas de motif « libggml » : les logs de chargement normaux
citent les .so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 07:21:21 +02:00
MichaelandClaude Opus 5 7ef2c956c5 Chargement du modèle : pourcentage visible ; interface allégée
La pastille « chargement… » restait identique de longues minutes sur un
gros GGUF — indiscernable d'un plantage. llama-server n'expose aucun
progrès, mais charger c'est LIRE le fichier : on rapporte les octets lus
par le process (/proc/<pid>/io, shards compris) à la taille du modèle et
la pastille affiche « chargement 43 % » (rafraîchie toutes les 5 s,
plafonnée à 99 — c'est /health qui dit prêt).

Carte Moteur : bouton « vérifier la version » retiré — « mettre à jour »
fait sa propre vérification et dit s'il n'y a rien de neuf.

Éditeur de preset : en conteneur, l'option de moteur « Personnalisé »
disparaît (rien à compiler dans l'image, la mise à jour passe par la
carte Moteur) ; un BIN non reconnu retombe sur le moteur de l'image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:19:21 +02:00
MichaelandClaude Opus 5 34218fae52 Mode code : le builder ne re-planifie plus à chaque tour, étiquettes d'outils
Le raisonnement n'étant pas conservé entre les tours (coût de contexte),
un modèle à reasoning fort re-déduisait TOUT son plan avant chaque appel
d'outil — des minutes de <think> pour écrire un fichier de 200 octets. Le
prompt du builder impose maintenant : plan écrit UNE fois dans PLAN.md,
une à deux phrases de réflexion entre les appels, chaque fichier complet
en un seul write.

UI : les outils du mode code (criteria, read, grep, glob, git_*, ask,
bash_bg/bash_tail) ont leurs étiquettes — ils retombaient tous sur
« mémoire ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:53:23 +02:00
MichaelandClaude Opus 5 a7587235c6 Panneau Fichiers : toute la discussion en une archive .zip
Bouton « .zip » dans le pied du panneau : la discussion entière — dossiers
et sous-dossiers compris — part en une archive, nommée d'après le titre de
la discussion. Même danse en trois temps que le téléchargement de fichier
(meta → brut, ou tranches base64 derrière le tunnel chiffré), liens
symboliques ignorés, parcours borné comme le calcul de taille du panneau.
Le scope « hors discussion » a son propre zip, sans le dossier des
discussions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:48:11 +02:00
MichaelandClaude Opus 5 53d23418e9 CI : gofmt + gopls sous Go 1.25 ; MCP : premier lancement npx/uvx patient
- gofmt sur trois fichiers oubliés par le dernier commit (ci/test rouge).
- gopls@latest exige Go ≥ 1.26 alors que l'image de build est en 1.25 avec
  GOTOOLCHAIN=local : l'étape Docker échouait net. GOTOOLCHAIN=auto télécharge
  le toolchain requis, borné à l'étape de build (build-push GHCR rouge).
- Serveurs MCP : npx/uvx téléchargent leur paquet au premier lancement, et le
  délai de connexion de 20 s tombait dessus (« enregistré mais connexion
  échouée : context deadline exceeded » depuis le catalogue). Délai à froid
  de 3 min pour ces deux lanceurs, et erreur de délai réécrite pour dire quoi
  faire (réessayer : le paquet reste en cache).
- README : note sur ce premier lancement, ligne mode Code dans le tableau
  des différences.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:09:37 +02:00
MichaelandClaude Opus 5 901d518f70 Mode Code : agent de code avec critères, vérification et LSP
Sélecteur Chat|Code par discussion dans le pied du composeur ; en mode
chat, une détection serveur suggère la bascule (puce ignorable, jamais
automatique). Conception reprise d'OpenFox (MIT), réécrite en Go — voir
NOTICE.md.

- Outils fichiers : read (lignes numérotées, borné), grep, glob, et le
  tracker « lu avant d'écrire » qui refuse write/edit sur un fichier non
  lu ou modifié depuis la lecture ; edit préserve les fins de ligne CRLF.
- Politique d'exécution : commandes catastrophiques refusées (rm -rf /,
  mkfs, reboot…), chemins bornés au dossier de la discussion en mode
  code, mutex par fichier.
- Critères d'acceptation : contrat posé via l'outil criteria (éditable
  dans l'UI), passe de vérification indépendante sur contexte isolé —
  seule habilitée à marquer « passed » — puis corrections plafonnées.
- Rôles embarqués (agents/*.md) : builder, planner, verifier, explorer,
  code-reviewer ; badge de rôle dans le fil.
- LSP : gopls / typescript-language-server / pyright (inclus dans
  l'image), diagnostics injectés dans le retour de write/edit.
- Git natif : git_status, git_diff, git_clone (borné à la discussion).
- Jobs d'arrière-plan bash_bg/bash_tail (serveur de dev, build long).
- Auto-retry : un appel d'outil écrit en texte (default_api:…,
  <tool_call>…) relance le tour une fois avec consigne corrective.
- Outil ask : question à choix rendue en carte à boutons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:58:57 +02:00
MichaelandClaude Opus 5 c6cc7d300b Test moteur OCI : bit exécutable sans objet sous Windows
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:58:04 +02:00
MichaelandClaude Opus 5 fb908b180b Perte de données en conteneur : alerte si /data n'est pas monté
Modèles, discussions et fichiers disparaissaient à chaque recréation du
conteneur quand /data n'était pas un volume : tout vivait dans la couche
éphémère. Loki le détecte maintenant (mountinfo) et le dit — bandeau rouge
dans l'UI (/api/status warn), avertissement en tête du journal, et
l'entrypoint exporte LOKI_HOME pour ses sous-commandes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:58:04 +02:00
LogiFlow 11a4aebf37 Merge pull request #11 from R0m1k3/claude/model-reasoning-without-instruction-9muczs
Raisonnement « aucune » : le dire dans la langue du gabarit
2026-08-18 12:54:27 +02:00
Claude fb1294d42c Raisonnement « aucune » : le dire dans la langue du gabarit
Le sélecteur envoyait seulement `reasoning_effort` dans le corps de la
requête. Ce champ n'agit que sur les gabarits qui le LISENT (gpt-oss et
apparentés). Un modèle hybride à la Qwen3 ne connaît que `enable_thinking` :
il recevait `reasoning_effort: none`, son gabarit l'ignorait, et il
continuait de réfléchir pendant que l'interface affichait « aucune ». Les
llama-server récents traduisent eux-mêmes `none` en `enable_thinking=false`,
mais les binaires plus anciens laissent simplement tomber le champ.

On joint donc `chat_template_kwargs` à la requête — même procédé que la
compaction, qui coupe déjà la réflexion ainsi :

  aucune (ou REASONING=off explicite) → {"enable_thinking": false}
  basse/moyenne/haute                 → {"reasoning_effort": "<niveau>"}
  auto                                → rien, le gabarit garde son défaut

Une clé REASONING absente n'est PAS une interdiction : sans consigne on ne
touche à rien. Une clé inconnue d'un gabarit est ignorée sans erreur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RodUJoPJDBvVKhA6S5msvb
2026-08-18 10:53:09 +00:00
LogiFlow 533ff7d0d1 Merge pull request #10 from R0m1k3/claude/discussion-sidebar-assistant-timing-wcv5li
Claude/discussion sidebar assistant timing wcv5li
2026-08-18 11:45:00 +02:00
Claude 69e89e5c2f Compteur de vitesse par bulle, et budget souple d'appels d'outils
Deux défauts révélés par un tour d'agent d'une heure (~50 appels d'outils)
sur un modèle très quantifié.

1. La vitesse affichée sous les réponses tombait de 17 tok/s à 0,9 au fil du
   tour, ce qui donnait à croire que le moteur s'effondrait. Il n'en était
   rien : un tour d'agent ouvre une bulle NEUVE après chaque appel d'outil
   (le flux repasse contentEl à null), mais les compteurs n'étaient jamais
   remis à zéro. Chaque bulle affichait donc le CUMUL de tout le tour divisé
   par le temps écoulé depuis le tout premier token — exécution des outils,
   pages web et prefill compris. La vitesse convergeait mécaniquement vers
   « tokens générés ÷ durée totale du tour ».

   Les compteurs sont maintenant remis à zéro à la CRÉATION de la bulle, ce
   qui couvre tout chemin qui en ouvre une neuve, aujourd'hui comme demain.
   Rejoué sur un tour synthétique où le moteur décode à 20 tok/s constants
   entre deux outils de deux minutes : 20,5 / 0,6 / 0,5 tok/s avant, 20,5 sur
   les trois bulles après. La durée « travail », elle, reste bien celle du
   tour entier — c'est sa définition.

2. Rien n'exerçait de pression sur un tour qui tourne en rond. Le plafond
   d'itérations avait été retiré en v0.6.3 (il coupait des recherches
   légitimes) et la déduplication d'appels ne rattrape pas ce cas : sa clé est
   « nom + arguments bruts », or relire le même fichier par tranches
   (`sed -n '1,80p'` puis `sed -n '80,160p'`) produit des clés différentes.

   D'où un budget SOUPLE : au-delà de 24 appels d'outils sur un tour, on
   rappelle au modèle combien il en a déjà faits et on lui demande de
   conclure. Le rappel revient à chaque palier en durcissant le ton, et ne
   coupe jamais le tour. Il est ajouté EN FIN d'historique, ce qui laisse
   intact le préfixe déjà en cache côté llama-server, et n'est pas persisté.
   `AGENT_BUDGET` dans config.env règle le palier, `off` le désactive.

   Élargir plutôt la clé de déduplication à la CIBLE de l'appel a été écarté :
   deux tranches d'un même fichier renvoient un contenu différent, les
   confondre casserait toute lecture paginée légitime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5UndZ9DedRXPuAoRXbmDb
2026-08-18 08:56:32 +00:00
Claude 3d8a72754c UI : anneau d'activité dans la liste des discussions, temps de travail sous les réponses
Deux manques dans la présentation d'un tour :

1. Rien ne disait sur QUELLE discussion un agent était en train de
   travailler. La ligne concernée porte maintenant un anneau qui tourne,
   à gauche du titre — et l'en-tête aussi, puisque la barre latérale est
   escamotée sur téléphone. L'état vient de deux sources : le flux SSE
   (instantané) et /api/conversations, qui expose désormais `busy` pour
   que la page ouverte en cours de génération s'anime sans attendre le
   rejeu du journal. L'indicateur est posé et retiré SANS redessiner la
   liste : un redessin relancerait l'animation à chaque événement.

2. Le pied des réponses ne montrait que la vitesse du moteur, jamais le
   temps que le tour avait pris. Il porte maintenant « travail 1 min 04 s »
   — de la question envoyée à la fin du tour, raisonnement, appels
   d'outils et attentes compris. La durée avance à la seconde pendant le
   tour et se fige au turn_done ; elle apparaît aussi sur l'indicateur
   « … », là où il n'y a pas encore de réponse sous laquelle écrire.
   La bulle de raisonnement gagne au passage sa propre durée.

La mesure est prise sur les HORODATAGES SERVEUR (événements `user` et
`turn_done`) : elle est donc juste en direct comme au rejeu, où tout
arrive d'un bloc côté client. Le compteur vivant se recale sur l'écart
entre l'horloge du navigateur et celle du serveur, réévalué à chaque
événement reçu en direct — un téléphone n'est pas à la même heure que la
machine.

« Masquer la vitesse de génération » ne masque plus que la vitesse : la
durée n'est pas une mesure de moteur, c'est ce que la réponse a coûté.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5UndZ9DedRXPuAoRXbmDb
2026-08-18 08:35:51 +00:00
MichaelandClaude Opus 5 88e1b38582 Images du fil : miniatures des deux côtés, loupe au clic
Une pièce jointe n'affichait que son nom de fichier, et une image posée
par l'IA sortait à sa taille NATIVE — .chatimg portait la classe mais
aucune règle CSS, donc une capture remplissait la colonne et poussait le
texte hors de vue.

- pièce jointe image : vignette dans le composeur (objectURL local, rien
  n'est encore parti au serveur) et tuile 200x160 dans le message envoyé,
  hydratée comme les captures (fetch authentifié + blob:) ;
- images de l'IA plafonnées à 280px de haut ;
- clic sur n'importe quelle image = loupe plein écran (Échap ou clic sur
  le fond pour fermer). La source est relue AU CLIC, l'hydratation
  remplaçant le src par un blob: après coup.

align-items:flex-start sur .msg-files au passage : une pastille texte
voisine d'une vignette s'étirait à sa hauteur et, arrondie à 999px,
devenait un gros ovale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:35:54 +02:00
MichaelandClaude Opus 5 781520afd8 Vision : la sonde /props s'authentifie
engineSeesImages interrogeait /props sans en-tête Bearer. Moteur lancé
avec --api-key (backend_serve.go), la sonde recevait 401, concluait
« pas de vision » et les deux appelants — pièces jointes et captures —
retombaient sur le texte seul : aucune image ne partait, projecteur
chargé ou non. Le modèle brodait à partir du seul chemin de fichier.

Vérifié en production : /props authentifié renvoie modalities.vision
true avec le mmproj bien passé au moteur.

authHeader est le helper déjà utilisé par tous les autres appels
internes ; c'était le seul client.Get nu du paquet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:57:30 +02:00
MichaelandClaude Opus 5 0a3af60715 Intensité du raisonnement dans la barre de saisie, angles moins arrondis
Le niveau de raisonnement n'existait que dans l'éditeur de preset : le changer
demandait d'ouvrir les réglages, éditer, enregistrer. Or ça se décide au moment
d'écrire le message.

- Nouvelle route /api/reasoning-effort (GET/POST), calquée sur /api/memory :
  elle écrit REASONING_EFFORT dans la config vivante, relue à chaque requête au
  moteur. Aucun redémarrage — la valeur voyage dans le corps de la requête.
- Liste blanche côté serveur : cette clé finit dans une requête au moteur, on
  n'y laisse pas passer une chaîne arbitraire venue du navigateur.
- La liste est grisée quand le raisonnement est coupé pour ce modèle, plutôt que
  masquée : le réglage reste trouvable, et l'infobulle dit où le rallumer.
- L'éditeur de preset garde le même réglage comme DÉFAUT du modèle. Appliquer un
  preset réécrit toute la config, donc il reprend la main sur le choix fait à la
  volée — les deux sous-titres le disent, sinon la double présence intrigue.

Angles : les rayons allaient de 5 à 26px selon les composants, ce qui donnait
des cartes très rondes. Tout est ramené à 4px (cartes, boutons, modales) et 3px
(petits éléments), y compris les formes multi-valeurs comme la barre de saisie
(26px 26px 0 0 → 3px 3px 0 0). Les pastilles (999px) et les ronds (50%) sont
laissés intacts : ce sont des interrupteurs et des avatars, pas des cartes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:31:37 +02:00
Michael ebe04f9d61 Merge branch 'feat/mcp-catalog-go' 2026-08-17 10:06:19 +02:00
MichaelandClaude Opus 5 847aad217b MCP : catalogue embarqué de serveurs connus
Ajouter un serveur MCP demandait d'écrire soi-même `npx -y @scope/paquet …`
dans la modale, en devinant le nom du paquet. Le catalogue liste une vingtaine
de serveurs connus, commande déjà renseignée, classés par catégorie.

- internal/loki/mcp_catalog.json est EMBARQUÉ dans le binaire (go:embed), pas
  interrogé sur le réseau : Loki tourne hors ligne, et rien de ce qui s'exécute
  sur la machine ne vient d'un annuaire distant. Pour proposer un serveur de
  plus : éditer le fichier et recompiler.
- Choisir une entrée n'installe rien. Ça préremplit la modale d'ajout existante
  et l'utilisateur relit la commande avant d'enregistrer — un serveur stdio
  exécute un process arbitraire, au même titre que l'outil bash du mode agent.
  Aucune ligne de mcp_client.go n'est touchée : le catalogue s'arrête à
  l'affichage, tout le reste passe par l'API MCP déjà en place.
- Une entrée dont le runtime manque (npx ou uvx absent) le signale dans la
  liste, plutôt que de laisser l'utilisateur découvrir l'échec au démarrage du
  serveur.
- Les entrées qui réclament une clé d'API la rappellent avant l'enregistrement,
  au lieu d'enregistrer un serveur qui ne peut pas se connecter.
- Les entrées Python publiées avant le SDK MCP 2.0 sont épinglées avec
  `uvx --with "mcp<2"` : sans cela elles plantent sur ImportError: McpError.
- Le Dockerfile gagne uv/uvx (~35 Mo). Sans lui, 9 des 21 entrées s'affichent
  sans pouvoir démarrer dans le conteneur.

Intensité du raisonnement (REASONING_EFFORT)

L'interrupteur Raisonnement était binaire. llama-server accepte
`reasoning_effort` dans /v1/chat/completions : `none` coupe le raisonnement,
toute autre valeur est passée au gabarit jinja du modèle.

- Nouvelle clé de preset REASONING_EFFORT — donc réglée par modèle, comme le
  reste du preset. Vide = on n'envoie rien et le gabarit garde son comportement.
- Pas de repli à prévoir : un gabarit qui ne lit pas la valeur l'ignore sans
  erreur. En pratique seuls gpt-oss et apparentés changent de comportement, et
  le sous-titre du réglage le dit — promettre un effet universel serait faux.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:06:14 +02:00
Claude e602adbbee Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-17 06:24:37 +00:00
Claude a1047b4e08 Boutons : leur rendre leur cadre
La refonte les voulait « sans contour » : trait transparent et aplat pris
sur --row-bg. Or --row-bg est exactement le fond des blocs qui les
contiennent (.slist, .rowbody). Un bouton posé dessus n'avait donc ni
trait ni écart de valeur : « démarrer / redémarrer / arrêter » et
« mettre à jour le moteur » se lisaient comme du texte, et rien ne disait
qu'on pouvait cliquer.

On revient au cadre d'avant la refonte — trait de 1 px et aplat un ton
au-dessus du bloc — pour que le bouton se détache quel que soit ce qu'il
y a derrière. La micro-interaction au survol reste : elle était bonne,
il lui manquait seulement quelque chose à animer, et le survol change
maintenant aussi la couleur du trait.

Les boutons-icônes de la saisie et de l'en-tête (envoyer, arrêter,
joindre, ☰) ne bougent pas : ils posent border:0 plus bas dans la
feuille et gardent leur allure nue, qui est la bonne pour un
pictogramme seul.

Vérifié en clair et en sombre, dans la barre latérale, les boîtes de
dialogue et les modales.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-17 06:24:35 +00:00
Claude 1dd1d32020 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 22:56:36 +00:00
Claude ee60ec8480 Réglages : rendre au congé la place de tourner
Les chevrons des sections débordaient dans l'arrondi de la carte, et le
rayon paraissait beaucoup trop fort. La cause n'était pas le rayon : la
carte des réglages avait perdu sa gouttière intérieure.

Le gabarit de carte (commit précédent) a déplacé le retrait vers
#side-scroll pour aligner Discussions, Réglages et Performance sur la
même largeur — mais il a mis le rembourrage de .sidegroup à zéro au lieu
de ne déplacer que le retrait EXTÉRIEUR. Les lignes allaient donc d'un
bord à l'autre : sur la première et la dernière, le chevron tombait dans
le congé de 20 px et semblait en sortir, et les filets de séparation
venaient buter dans la courbe.

On reprend le retrait d'avant la refonte — 16 px, exactement la valeur
qui fonctionnait — et tout rentre dans l'ordre sans toucher au rayon.

Vérifié en clair, en sombre et en largeur téléphone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 22:55:25 +00:00
Claude b378311873 Moteur : mettre à jour llama.cpp sans reconstruire l'image
llama.cpp publie plusieurs versions par jour ; l'image de Loki ne se
reconstruit qu'à une mise à jour de Loki. Le moteur y était donc figé à
la date du dernier build, et le rattraper imposait un rebuild complet de
2,6 Go pour un composant qui en pèse 170 Mo. Le panneau « Moteur » ne le
disait même pas : il annonçait « rien à mettre à jour ici ».

Réglages → Moteur affiche maintenant la version qui tourne (bXXXXX et son
commit, lus dans la bannière du binaire — jusqu'ici invisibles ailleurs
que dans le journal du moteur) et la met à jour en un clic.

Où le moteur est pris. Pas dans les releases GitHub de llama.cpp : elles
ne contiennent AUCUN binaire CUDA pour Linux, et le mode « précompilé »
retomberait sur Vulkan, donc sur une régression pour une carte NVIDIA. La
seule distribution CUDA/Linux officielle et précompilée est l'image de
conteneur — celle-là même dont l'image de Loki hérite. On lit son
manifeste OCI et on ne télécharge que les couches qui portent /app, en
descendant du sommet : le runtime CUDA (2 Go) et la base système sont
déjà là. S'arrêter au binaire ne suffit pas — llama.cpp le range dans une
couche et ses .so dans la précédente — d'où une règle d'arrêt sur
« binaire + libggml-base + libllama », et un garde-fou de taille qui
interdit de descendre jusqu'au runtime.

Le moteur atterrit dans /data/engine/<version>/, donc sur le volume de
données : il survit à un docker compose pull. La variante (CUDA, Vulkan,
SYCL, MUSA, CPU) est déduite des backends ggml posés à côté du moteur
courant — le conteneur ne sait pas de quelle image il vient, et faire
retenir « server-cuda » à l'utilisateur serait un piège.

Le risque, et ce qui le couvre. La mise à jour apporte llama.cpp, pas le
runtime CUDA, qui reste celui de l'image : un llama.cpp compilé pour un
CUDA plus récent ne chargerait pas son backend GPU. Le symptôme serait
silencieux — tout marche, mais sur le processeur. Le nouveau moteur est
donc lancé à blanc avant toute bascule ; il est refusé s'il ne démarre
pas, ET s'il ne voit plus aucune carte alors que le moteur courant en
voyait. Dans les deux cas le moteur courant n'est pas touché, et celui de
l'image reste intact : « revenir au moteur de l'image » y ramène en un
clic, sans réseau.

Vérifié de bout en bout contre le vrai ghcr.io (166 Mo, 7 s, toutes les
bibliothèques et leurs liens de version présents) et, pour les chemins
d'échec, contre un faux registre.

LOKI_OCI_REGISTRY permet de viser un miroir quand ghcr.io n'est pas
joignable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 20:43:47 +00:00
Claude 54504cd1d5 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 20:00:38 +00:00
Claude e1d0e8f04a Discussions et moniteur : le gabarit de carte des réglages
La maquette posait ces deux blocs à plat, pleine largeur — et c'est ce que
j'avais suivi. Dans l'application réelle, ils voisinent avec les sections de
réglages, qui sont des CARTES : deux traitements pour deux listes qui se
touchent, et la barre latérale se lisait comme deux morceaux collés. Ils
reprennent donc le gabarit dominant de la colonne (.sidegroup) : fond de
panneau, rayon de 20, ombre discrète, lignes séparées d'un filet et rognées par
overflow plutôt qu'arrondies une à une.

La gouttière latérale revient (le plein format n'a plus lieu d'être), le titre
« Performance » devient une étiquette de groupe posée AU-DESSUS de sa carte —
comme « Paramètres » au-dessus de la sienne — et la sélection reste un aplat
plein, un cadre ferait un second contour à l'intérieur du premier.

Corrigé au passage : la remise à zéro de marge sur la dernière jauge. Elle
datait d'un temps où il n'y avait qu'un conteneur ; depuis que les cartes
graphiques et la mémoire vive sont deux blocs distincts, la dernière jauge de
CHAQUE bloc perdait sa marge et « Mémoire vive » venait se coller à la VRAM.
C'est le rembourrage bas de la carte qui absorbe désormais la dernière marge.

Vérifié dans les deux thèmes, avec deux cartes graphiques et trois discussions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 20:00:26 +00:00
Claude af83257c76 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 19:45:54 +00:00
Claude b518e98b43 Accès OpenAI : servi par Loki, par domaine ou par IP
L'endpoint compatible OpenAI n'était pas servi par Loki : le panneau annonçait
l'adresse de llama-server lui-même, http://<ip>:8080/v1. Dans le déploiement de
référence de ce fork, cette adresse ne peut joindre personne — le port 8080
n'est pas publié par le conteneur, l'entrypoint sème HOST=127.0.0.1, et l'IP
annoncée est celle du bridge Docker. L'autre voie proposée, « exposer en public
(ajean.link) », exigeait un jeton de relais que ce fork ne permet plus
d'obtenir : l'interrupteur ne pouvait que renvoyer vers un panneau supprimé.

Désormais, Loki sert /v1/* SUR SON PROPRE PORT et relaie vers le moteur. L'API
est donc joignable partout où l'interface l'est — IP du réseau local, nom de
domaine, reverse proxy — sans publier de second port ni ouvrir le moteur.

Serveur
- mountOAI (llm_oai.go) monte /v1/ sur le mux, et RIEN d'autre : ni /metrics,
  ni /props, ni /slots, qui divulgueraient le modèle chargé et l'état des slots.
  Le filtre interne d'oaiHandler reste en seconde barrière.
- requireCompletionKey (web_auth.go) garde cette surface avec la clé des
  COMPLÉTIONS, pas celle de pilotage : un client OpenAI n'a qu'un en-tête
  Authorization, et on veut pouvoir lui donner l'accès au modèle sans le droit
  de redémarrer la machine. Erreurs au format d'OpenAI (body.error.message), que
  les SDK savent présenter. Le préflight CORS passe sans clé — il n'en porte
  jamais, et le refuser casserait tout client tiers de navigateur.
- effectiveAPIKeyErr (backend_config.go) devient la source unique de la clé
  exigée : base d'abord, config.env en repli, exactement comme le moteur. Sans
  ce miroir, un API_KEY résiduel donnait un endpoint « ouvert » côté Loki et un
  401 côté moteur, sans rien pour l'expliquer. Lecture ratée = refus, jamais
  ouverture (même raisonnement que readWebKeyErr).
- oaiHandler passe à ReverseProxy.Rewrite : le port du moteur est relu à chaque
  requête au lieu d'être figé à la construction — il visait l'ancien port dès
  qu'on changeait PORT, jusqu'au redémarrage de Loki.
- withLocalAuth (relay_link.go) n'injecte plus la clé de pilotage sur /v1 : elle
  aurait été refusée par la garde, et surtout relayée au moteur. Le trafic du
  tunnel est marqué (en-tête effacé avant d'être posé, sinon un client le forge)
  et la surface y reste fermée tant que oai_public est faux — la promesse du
  tunnel est tenue.

Adresse affichée
- web_public_url.go : normalisation d'une adresse publique saisie à la main
  (schéma ajouté, /v1 recopié toléré, chemin refusé), origine de la requête via
  Host + X-Forwarded-Proto, et la règle de priorité entre les deux.
- Le calcul quitte le navigateur pour le serveur : c'est la concaténation côté
  client qui produisait l'adresse fantôme.

Interface
- Le panneau perd l'interrupteur ajean.link et l'interrupteur d'écoute LAN — ce
  dernier n'a plus d'objet, et deux interrupteurs pour « rendre l'IA joignable »
  était la confusion à lever. La route /api/network et `loki network` restent
  pour qui veut exposer le moteur en direct.
- Il gagne un champ « adresse publique » (facultatif, pour le reverse proxy) et
  un avertissement rouge tant qu'aucune clé n'est définie — l'endpoint est
  maintenant ouvert PARTOUT où l'interface l'est, ça ne se dit pas à voix basse.
  Le démarrage de `loki web` le crie aussi.

Vérifié bout en bout sur le serveur réel : liste des modèles à travers Loki avec
la clé (200), sans la clé (401), et complétion en streaming dont les tokens
arrivent espacés de 120 ms — le flux traverse bien le double proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 19:45:43 +00:00
Claude 1c031015de Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 19:33:32 +00:00
Claude 9c7f5283cf Flèche d'envoi : la centrer pour de bon
La flèche était décalée de 8 px vers la gauche dans son pavé — mesuré, pas
supposé : centre du bouton 1339, centre du dessin 1331.

La cause n'était pas dans la feuille de style mais dans le JS. syncSendBtn
montrait et cachait les deux boutons avec `style.display = 'inline-block'` ; ce
style INLINE l'emportait sur le `display:flex` de la règle commune. Le bouton
gardait donc sa boîte de 34 px, mais son icône de 18 px se collait au bord
gauche, `justify-content` n'ayant plus rien à centrer. Invisible tant que
l'icône était un glyphe de texte (qui remplit sa ligne), flagrant depuis qu'elle
est un SVG de taille fixe.

L'état « génération en cours » passe désormais par un attribut sur <html>
(data-busy) et c'est la feuille qui décide lequel des deux boutons s'affiche —
comme pour le thème, la barre latérale et le panneau Fichiers. Le style inline
`display:none` du bouton d'arrêt disparaît aussi du gabarit.

Vérifié au pixel dans les deux états : écart nul entre le centre du bouton et
celui du dessin, pour les trois commandes de la barre (dépôt, fichiers, envoi)
comme pour le bouton d'arrêt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 19:33:22 +00:00
Claude c03224fcd6 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 19:16:52 +00:00
Claude fef8cca2c4 Un seul jeu d'icônes pour toute l'interface
Les deux icônes de la carte de saisie venaient de la maquette ; tout le reste
était encore des GLYPHES DE TEXTE — ↻ × ☰ ⌫ 🗑 ✎ ↓ ▸ ▣ ·. C'est le système
d'exploitation qui les dessinait : le même bouton sortait fin sur macOS, gras et
bleuté sur Windows (émojis en couleur), et absent d'une machine sans police
d'émojis. Aucune cohérence possible, et rien à régler depuis la feuille de style.

Elles passent en SVG, tracées comme celles de la maquette : boîte de 24, trait de
1,8, extrémités arrondies, couleur héritée. Un seul fichier les décrit
(03-icons.js) — menu, fermer, rafraîchir, crayon, corbeille, téléchargement,
flèche, dossier, fichier, image, plus, chevron.

Touchées : le ☰ du téléphone, les croix de modale, les deux rafraîchissements,
le « + » des sections, la flèche de retour en bas, le téléchargement Hugging
Face, « vider la discussion », les lignes du panneau Fichiers (dossier, image,
fichier, télécharger, supprimer), renommer/supprimer une discussion, éditer un
serveur MCP.

Deux pièges rencontrés, notés dans la feuille :

- Une règle de centrage qui pose `display:inline-flex` sur ces boutons les rend
  VISIBLES en permanence : plusieurs se masquent justement par `display:none`
  (le ☰ hors téléphone, le « + » d'une section fermée, la flèche de retour en
  bas). Le ☰ s'est ainsi retrouvé par-dessus l'en-tête sur grand écran. Le
  centrage passe donc par le rembourrage pour ceux-là.
- Les titres de panneau (.stitle) et l'intertitre « Paramètres » gardaient le
  monospace en capitales de la charte précédente ; ils suivent maintenant le
  seul style de titre de la maquette, celui de « Performance ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 19:16:43 +00:00
Claude c3a1885987 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 19:03:17 +00:00
Claude 82136d6e0d Palettes officielles Stitch, géométrie et moniteur relevés
Le projet Stitch porte deux planches que je n'avais pas vues — « Official
Palette for Loki AI v1 » et « Official Dark Mode Palette for Loki AI ». Leurs
couleurs NOMMÉES font désormais foi, et elles corrigent le relevé précédent, que
la compression JPEG des captures avait décalé :

  clair   Main BG #F5F6F8 · Sidebar BG #ECECEC · Primary Accent #95A899
          Typography #333333
  sombre  Deep Navy #0F172A · Slate Surface #1E293B · Sage Accent #84A98C

Le thème sombre retrouve donc exactement les deux teintes du cahier des charges,
que mon relevé sur capture avait fait dériver vers #1E2834/#253141. Les surfaces
non nommées (carte de réponse, carte de saisie, piste de jauge, bulle sombre)
restent relevées sur les écrans de la maquette, en pleine résolution cette fois.

Géométrie, elle aussi mesurée sur la maquette :

- Bulles et carte de réponse : rayon 10 px (contre 16/18 et un coin rentré), la
  carte sans cadre — c'est l'ombre qui la détache.
- Carte de saisie : rayon 14 px et CADRE SAUGE, le seul contour coloré de
  l'écran. Il retombait au gris commun, une règle de coque plus bas dans la
  feuille reprenant la main sur la couleur.
- Liste des discussions : lignes pleine largeur séparées d'un filet, bande plate
  pour l'active — la maquette n'a ni pastille arrondie ni gouttière.
- Moniteur machine : titre en casse normale (le monospace capitales de la charte
  précédente y criait), libellé + pourcentage à la même échelle, jauge de 7 px
  entièrement arrondie. La sous-ligne chiffrée passe en infobulle et le bouton
  de repli disparaît : la maquette n'en a pas.
- Une jauge de VRAM par carte, en plus de la charge : sur une machine à une
  seule carte on retombe sur les trois jauges de la maquette (GPU, VRAM, RAM),
  et pour du LLM c'est la VRAM qui décide si un modèle tient.
- Échelle typographique des planches : corps 16 px dans le fil et la saisie,
  secondaire 14 px medium dans la barre latérale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 19:03:08 +00:00
Claude f9ff17a614 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 18:50:21 +00:00
Claude 18edc9d3ed Palette relevée sur la maquette v1 (claire et sombre)
Vérification faite contre la source : le projet Stitch « Loki AI Chat Modern
Redesign v1 », écrans clair et sombre. Les couleurs sont désormais MESURÉES sur
la maquette, plus approchées à l'œil — et elles posent une hiérarchie de
surfaces que l'intuition inverse :

                     maquette clair   maquette sombre
  barre latérale     #E4E8E9          #0F1829     ← la plus sombre des trois
  fil                #EFF3F4          #1E2834
  carte de réponse   #E5E9EA          #253141     ← s'écarte du fil
  carte de saisie    #EFF3F6          #1F2734     ← la plus claire
  bulle utilisateur  #7EA58A          #334257
  ligne active       #D8DCDF          #22293B
  piste de jauge     #D5D9DC          #1E2738

Ce qui change concrètement :

- La barre latérale était la surface la plus CLAIRE (#FAFBFA) ; elle est la plus
  sombre. Idem pour la carte de réponse, qui était quasi blanche alors que la
  maquette la fait descendre sous le fil. En sombre elle monte au-dessus : dans
  les deux cas la carte se détache en s'éloignant du fond, jamais en allant vers
  le blanc.
- La bulle de l'utilisateur ne suit plus l'accent. Elle a son rôle propre
  (--user-bg) : sauge en clair, ARDOISE en sombre — la maquette réserve le vert
  aux jauges dès que le fond est sombre, ce que le vert unique ignorait.
- --accent redevient une couleur d'ENCRE (liens, icônes, sélection), lisible sur
  les deux fonds ; --accent-fill porte les aplats. Sans cette séparation, la
  sauge claire de la maquette (#7EA58A) donnait des icônes à 2:1 de contraste.
- La sélection dans la liste des discussions est un aplat neutre (#D8DCDF) et
  non une teinte verte : la maquette n'y met pas de second accent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 18:50:12 +00:00
Claude cd1792df41 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 18:37:05 +00:00
Claude 040c32a6d6 Blocs de code : un fond qui se détache de la carte de réponse
Depuis que la réponse de l'IA est une carte, le bloc de code avait exactement
son ton (#F4F6F5 dans une bulle #F6F8F7) : l'encadré disparaissait dans la
bulle, et un long prompt collé dans une réponse ne se distinguait plus du texte
qui l'entoure. --code-bg est désormais nettement plus soutenu que --bubble, dans
les deux variantes, avec un filet net. Le code EN LIGNE suit la même règle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 18:36:56 +00:00
Claude 41910b01ec Merge remote-tracking branch 'origin/claude/discussion-file-linking-nzgd0d' into claude/discussion-file-linking-nzgd0d 2026-08-16 18:35:25 +00:00
Claude 45f1ec4941 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 18:35:14 +00:00
Claude 6efd2a362e Barre de saisie cohérente et étiquettes d'identité stables
Deux retours, deux vrais défauts.

Boutons de la saisie
  Ils avaient trois générations de règles empilées — pilule large « Envoyer »,
  aplat noir, aplat sauge — si bien que chaque bouton tirait sa taille, sa forme
  et son alignement d'une couche différente : rond pour l'envoi et l'arrêt,
  carré de 15 px pour l'icône stop contre 16 pour ses voisines, et un
  align-self:center sur deux boutons seulement (les deux autres tombaient au
  bas de la pilule). Une seule règle les décrit désormais : 34x34, coins doux
  de 10 px — jamais un rond, la maquette n'en a pas —, icône de 18 px centrée,
  fond seulement au survol. Seule la couleur les distingue : atténué pour
  joindre et fichiers, sauge pour envoyer, rouille pour arrêter.

Étiquettes des bulles
  Une bulle changeait de nom en cours de route. Envoyée en direct, elle
  s'appelait « USER » (confirmPending réécrivait l'étiquette en dur) ; rejouée
  au chargement, elle portait l'avatar et le prénom. Même chose côté réponse :
  labelTokens remplaçait « Loki » par « assistant · 75 tok · 21.5 tok/s »
  pendant la génération, et le nom ne revenait qu'au rechargement de la page.
  L'étiquette porte maintenant l'identité DANS TOUS LES CAS ; les mesures de
  génération vont dans la ligne dédiée, à l'endroit exact où les stats de fin
  de tour les écrivent déjà. L'envoi en cours se lit à la bulle grisée et à son
  infobulle, plus à un libellé qui écrase le prénom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 18:35:04 +00:00
Claude 30be0a5909 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 18:19:11 +00:00
LogiFlow da4dcbce57 Merge pull request #9 from R0m1k3/main
Merge pull request #8 from R0m1k3/claude/discussion-file-linking-nzgd0d
2026-08-16 20:18:27 +02:00
Claude a44f3c763a Merge remote-tracking branch 'origin/claude/discussion-file-linking-nzgd0d' into claude/discussion-file-linking-nzgd0d 2026-08-16 18:15:39 +00:00
Claude 276a8287cb Refonte : modèle chargé, attribution, ombres, flèche, favicon
Retours sur la refonte, cinq corrections.

Modèle affiché
  Le sélecteur de l'en-tête ne montrait que le PRESET actif — or un preset n'est
  « actif » que si son empreinte correspond exactement à la configuration. Une
  configuration écrite à la main, héritée de l'image ou modifiée d'un cran depuis
  le dernier preset n'en désigne aucun, et l'en-tête restait muet alors qu'un
  modèle tournait. Il retombe désormais sur le nom du fichier de la
  configuration active (loadCfg le lui donne), et l'infobulle porte le chemin
  complet.

Attribution
  « fork de AJEAN » revient sous le nom de l'app, dans l'en-tête de la barre
  latérale, au lieu du pied de la zone défilante.

Ombres
  Les bulles du fil et la carte de saisie portent une vraie ombre douce
  (--shadow-card), comme la maquette — le filet de 1 px ne les décollait pas du
  fond.

Bouton d'envoi
  Chevron sauge sur fond transparent, à la place de la pastille verte pleine :
  c'est le dessin de la maquette, et il suit alors la même charte que ses
  voisins (dépôt, fichiers).

Favicon
  Carré sauge (#5E7F5A) au lieu du carré noir, aux quatre endroits qui le
  dessinent : favicon SVG, icône d'accueil iOS, icône du .exe (icon.ico) et PNG
  macOS — tous rendus depuis sys_brand_icon.go, seule source du dessin. La
  variante « template » de macOS reste noire : le système ne lit que son alpha.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 18:15:24 +00:00
LogiFlow ce23dccf03 Merge pull request #8 from R0m1k3/claude/discussion-file-linking-nzgd0d
Claude/discussion file linking nzgd0d
2026-08-16 19:48:37 +02:00
LogiFlow 5522adfd9e Merge pull request #7 from R0m1k3/main
Merge pull request #6 from R0m1k3/claude/discussion-file-linking-nzgd0d
2026-08-16 19:48:18 +02:00
Claude f99e5b59b8 Refonte de l'interface : direction « Sober Tech »
Reprise du design d'après la maquette fournie, sans changer d'architecture :
l'UI reste du HTML/CSS/JS assemblé dans le binaire (voir plus bas).

Charte
- Palette ardoise + sauge en remplacement des bruns « Terre ». Deux variantes :
  claire par défaut (celle de la maquette) et « Deep Dark » (fond #0F172A,
  cartes #1E293B), à un clic depuis l'en-tête. Une variable --on-accent porte ce
  qui s'écrit sur les aplats de sauge, pour que le contraste tienne dans les
  deux variantes.
- Typographie Inter (interface) + JetBrains Mono (code, chiffres, chemins), en
  sous-ensemble latin, embarquées comme l'étaient Bricolage et IBM Plex Mono —
  toujours aucune requête vers un service de polices.
- Boutons sans contour, fond au survol, léger enfoncement au clic.
- Coque à plat : plus de cartes flottantes séparées par une gouttière, la barre
  latérale est collée au bord et le fil occupe tout le reste.

Coque
- Barre d'en-tête : titre de la discussion + sélecteur de modèle. Changer de
  preset demandait d'ouvrir la barre latérale et de descendre jusqu'aux
  presets ; c'est désormais une liste déroulante, toujours visible.
- Barre latérale en trois zones — marque, contenu défilant, moniteur machine —
  et escamotable d'un clic sur grand écran. Les discussions y passent au
  premier plan, avec une recherche textuelle (filtrage côté client : la liste
  est déjà chargée, sans les messages) ; les réglages descendent sous un repli
  unique, d'où openDetails(), qui déplie aussi les parents.
- Moniteur machine en pied de colonne : une jauge par ressource (charge GPU,
  puis VRAM et température en détail, mémoire vive), sauge jusqu'à 75 %, ambre
  puis rouille quand la machine sature. Il remplace la section « Machine », qui
  disait la même chose en plus verbeux et en plus loin.
- L'option « barre latérale escamotable » disparaît d'Apparence : elle faisait
  de la barre un tiroir posé SUR la conversation, là où le bouton d'en-tête
  l'escamote pour de bon. Deux mécanismes concurrents pour la même intention.

Fil et saisie
- La réponse de l'IA redevient une carte, la question un aplat de sauge : sur un
  fond de fil coloré, du texte nu flottait sans ancrage.
- Saisie en pilule, ses outils (joindre, fichiers, envoyer) dans la barre
  elle-même. L'envoi est une pastille ronde à flèche ; le mot « Envoyer » reste
  porté par aria-label et l'infobulle.

Corrections croisées
- L'heure d'une discussion prenait la moitié de la ligne (flex:1 hérité de
  `.preset>span`), le titre était tronqué au premier mot.
- Une liste déroulante posée directement dans une ligne de modale débordait
  sous son intitulé quand sa valeur était longue (chemin de destination).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 16:17:59 +00:00
Claude 7310c84129 Bouton d'envoi : « Envoyer » au lieu de « send »
Le reste de l'interface est en français ; seul ce bouton était resté en anglais.
L'identifiant #send et la fonction send() ne changent pas — c'est le libellé
affiché qui est traduit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 15:49:19 +00:00
LogiFlow b9d2e88e64 Merge pull request #6 from R0m1k3/claude/discussion-file-linking-nzgd0d
Un dossier de fichiers par discussion
2026-08-16 17:48:19 +02:00
Claude 001d633750 Un dossier de fichiers par discussion
Les fichiers du chat vivaient dans un pot commun : on changeait de discussion
et on revoyait les mêmes pièces jointes, et supprimer une discussion laissait
derrière elle tout ce qu'on y avait déposé ou fait écrire à l'agent (seules ses
captures, déjà rangées par identifiant, partaient avec).

Chaque discussion a désormais son dossier, <workspace>/discussions/<id>/ :

- les dépôts (uploads/), les captures (captures/) et ce que l'agent écrit y
  atterrissent ; le shell et les chemins relatifs du modèle y sont résolus ;
- le panneau Fichiers s'ouvre sur ce dossier et n'en sort pas, et se redessine
  quand la discussion change (bascule ou vidage, signalés par le flux SSE) ;
- supprimer une discussion — ou la vider — emporte ses fichiers. Les deux gestes
  le disent maintenant avant de demander confirmation ; « clear chat » en
  demandait aucune.

La racine du dossier de travail reste la borne de sécurité : les liens des
anciens messages (uploads/x.pdf, captures/<id>/y.jpg) continuent d'ouvrir leur
fichier par un chemin de repli. Au démarrage, une migration range les captures
dans le dossier de leur discussion et rend chaque dépôt à la discussion qui le
mentionne dans son journal ; ce que personne ne réclame reste à la racine,
atteignable par le bouton « hors discussion » du panneau, qui disparaît une fois
le ménage fait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 15:33:55 +00:00
LogiFlow d42c334b9a Merge pull request #5 from R0m1k3/claude/ajean-loki-container-fork-aep9r2
Claude/ajean loki container fork aep9r2
2026-08-16 14:37:02 +02:00
LogiFlow 59ebbf6f8d Merge pull request #4 from R0m1k3/claude/ajean-loki-container-fork-aep9r2
Chercher et installer un modèle depuis Loki
2026-08-16 13:04:13 +02:00
LogiFlow 948aa5c54b Merge pull request #3 from R0m1k3/claude/ajean-loki-container-fork-aep9r2
Le fil ne passe plus sous la carte de saisie
2026-08-16 11:14:28 +02:00
363 changed files with 82260 additions and 2734 deletions

No files matched your search

+14
View File
@@ -15,6 +15,17 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
# L'image runtime CUDA de llama.cpp (~4 Go) et Chromium/Playwright
# (~1 Go) tiennent large dans les ~14 Go libres d'un runner neuf, mais
# ces trois répertoires ne servent à rien ici et rendent une dizaine de
# gigaoctets en moins d'une minute. Gardé depuis l'époque où l'image
# compilait whisper.cpp avec CUDA : la marge reste bonne à prendre.
- name: Libérer de l'espace disque
run: |
df -h /
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
df -h /
- name: Setup Buildx
uses: docker/setup-buildx-action@v3
@@ -45,5 +56,8 @@ jobs:
LOKI_VERSION=${{ github.sha }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Le cache de couches réutilise l'étape asrfetch (téléchargement du
# binaire de dictée) tant que ses lignes du Dockerfile ne bougent
# pas : seul le code Go et l'assemblage de l'image se refont.
cache-from: type=gha
cache-to: type=gha,mode=max
+9
View File
@@ -13,3 +13,12 @@
Thumbs.db
.idea/
.vscode/
# Ancienne application Python/React (fork pré-Go), plus utilisée : elle traîne
# encore sur certains postes de développement et ne doit JAMAIS revenir dans le
# dépôt — un « git add -A » distrait y a déjà versé 94 Mo de node_modules.
/backend/
/frontend/
.pytest_cache/
__pycache__/
node_modules/
+70 -1
View File
@@ -14,6 +14,17 @@
# Déclaré avant le premier FROM : requis pour être utilisable dans un FROM.
ARG LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda
# ⚠️ AVANT le premier FROM, obligatoirement. Un ARG posé entre deux étapes
# appartient à l'étape où il apparaît ; les « ARG SHERPA_* » nus de l'étape
# asrfetch héritent du scope GLOBAL — c'est-à-dire d'ici. Placés plus bas, ils
# hériteraient d'une valeur VIDE, et l'étape téléchargerait une URL tronquée.
# Un test (TestDockerfileArgFlagsGlobal) verrouille cette position.
#
# La version est ÉPINGLÉE, et l'empreinte avec : le binaire de dictée est
# exécuté sur la machine de l'utilisateur, il ne se prend pas « au dernier ».
ARG SHERPA_ONNX_VERSION=v1.13.7
ARG SHERPA_ONNX_SHA256=2aa3965b37aa235e56aa3ebbb94942c20a23960059a006b4442b4bf893967983
# ── Étape 1 : binaire Go loki ───────────────────────────────────────────
FROM golang:1.25 AS gobuild
WORKDIR /src
@@ -23,6 +34,45 @@ COPY cmd/ cmd/
COPY internal/ internal/
COPY tools/ tools/
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
# gopls : serveur LSP Go, consommé par le vérificateur de code du mode code
# (lsp.go). Compilé ici parce que l'image finale n'a pas de toolchain Go.
# GOTOOLCHAIN=auto : gopls@latest exige régulièrement un Go plus récent que
# celui de l'image (v0.23 veut ≥ 1.26 quand l'image est en 1.25) ; en local,
# l'installation échouait net. Auto télécharge le toolchain requis — coût
# limité à cette étape de build.
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
# ── Étape 1 bis : serveur de dictée (Parakeet via sherpa-onnx) ──────────
# On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le
# changement de fond par rapport à whisper.cpp, qu'il remplace : deux étapes de
# compilation (dont une CUDA de ~200 fichiers nvcc, tuée par l'OOM du runner
# plus d'une fois) laissent place à une extraction de 35 Mo.
#
# Le build « static-no-tts » lie sherpa-onnx ET onnxruntime en statique : rien à
# fournir côté image en dehors de la libc et de libstdc++, déjà présentes. Il
# n'existe qu'en variante CPU — c'est assumé : un modèle de 0,6 B en int8 sur
# des tranches de quelques secondes n'a pas besoin de la carte, qui reste au
# moteur de chat. La variante CUDA existe mais tirerait onnxruntime-gpu et
# cuDNN dans l'image, pour un gain sans intérêt à cette taille de tranche.
#
# L'empreinte est vérifiée : le binaire s'exécute sur la machine de
# l'utilisateur, une release remplacée en amont ne doit pas passer en silence.
FROM debian:12-slim AS asrfetch
ARG SHERPA_ONNX_VERSION
ARG SHERPA_ONNX_SHA256
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates bzip2 \
&& rm -rf /var/lib/apt/lists/*
RUN set -eu; \
nom="sherpa-onnx-${SHERPA_ONNX_VERSION}-linux-x64-static-no-tts"; \
curl -fsSL -o /tmp/sherpa.tar.bz2 \
"https://github.com/k2-fsa/sherpa-onnx/releases/download/${SHERPA_ONNX_VERSION}/${nom}.tar.bz2"; \
echo "${SHERPA_ONNX_SHA256} /tmp/sherpa.tar.bz2" | sha256sum -c -; \
mkdir -p /out; \
tar -xjf /tmp/sherpa.tar.bz2 -C /out --strip-components=2 \
"${nom}/bin/sherpa-onnx-offline-websocket-server"; \
chmod +x /out/sherpa-onnx-offline-websocket-server; \
/out/sherpa-onnx-offline-websocket-server --help >/dev/null 2>&1 || true
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
FROM ${LLAMACPP_IMAGE} AS runtime
@@ -37,12 +87,26 @@ LABEL org.opencontainers.image.revision="${LOKI_VERSION}" \
# via npx ; tini en PID 1. curl et libgomp1 sont déjà dans l'image amont.
# Node 22 depuis NodeSource et non depuis Ubuntu (qui livre Node 18) : Playwright
# et la plupart des serveurs MCP réclament Node ≥ 20.
# python3 + python3-venv : l'installeur et le serveur du moteur Strata
# (backend_strata.go) sont en Python ≥ 3.10 et créent leur propre .venv dans
# /data/strata. Le moteur et le modèle ne sont PAS dans l'image : téléchargés
# à la demande depuis Paramètres → Moteur.
RUN apt-get update && apt-get install -y --no-install-recommends \
git tini ca-certificates curl gnupg \
git tini ca-certificates curl gnupg python3 python3-venv \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# Serveurs LSP du mode code (lsp.go) : TypeScript/JavaScript et Python via
# npm, Go via gopls compilé à l'étape 1. Un serveur absent désactive juste son
# langage — mais autant livrer l'image complète.
RUN npm install -g --no-audit --no-fund typescript typescript-language-server pyright && npm cache clean --force
# uv/uvx : lanceur des serveurs MCP écrits en Python (mcp-server-git, -fetch,
# -time, sqlite, docker…). Sans lui, une bonne partie du catalogue embarqué
# s'affiche mais ne peut pas démarrer. Deux binaires statiques, ~35 Mo.
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
# Playwright + Chromium : l'outil web_screenshot capture une page RENDUE
# (JavaScript exécuté), ce que le moteur web intégré ne sait pas faire.
# Coût assumé : ~500 Mo à 1 Go d'image. Mettre PLAYWRIGHT=0 pour bâtir une image
@@ -66,6 +130,10 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
fi
COPY --from=gobuild /out/loki /usr/local/bin/loki
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
# Dictée vocale (POST /api/transcribe). Le modèle (~500 Mo) n'est PAS dans
# l'image : téléchargé à la demande dans /data/asr/.
COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server /usr/local/bin/sherpa-onnx-offline-websocket-server
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
@@ -74,6 +142,7 @@ ENV LOKI_CONTAINER=1 \
LOKI_HOME=/data \
LOKI_MODEL_DIRS=/models \
LOKI_ENGINE_BIN=/app/llama-server \
LOKI_ASR_SERVER=/usr/local/bin/sherpa-onnx-offline-websocket-server \
LD_LIBRARY_PATH=/app \
NVIDIA_VISIBLE_DEVICES=all \
NVIDIA_DRIVER_CAPABILITIES=compute,utility
+42
View File
@@ -19,6 +19,48 @@ La licence d'origine est conservée à l'identique dans [`LICENSE`](LICENSE).
pour que l'UI puisse redémarrer le moteur (changement de modèle).
- **`loki config get/set`** : lecture/écriture non interactive de la
configuration, utilisée par l'entrypoint Docker.
- **Mode code** (`internal/loki/code_*.go`, `lsp.go`, `agents/`) : agent de
code avec critères d'acceptation, passe de vérification indépendante,
outils read/grep/glob, politique d'exécution, diagnostics LSP, outils git,
jobs d'arrière-plan et relance sur appel d'outil textuel. La conception
(contrat builder/verifier, tracker « lu avant d'écrire », auto-retry
patterns, table languages.json) est reprise
d'**[OpenFox](https://github.com/co-l/openfox)** (MIT) et réécrite en Go
pour ce fork ; les prompts de rôles sont des réécritures originales. S'y
ajoutent depuis d'autres idées reprises d'OpenFox et réimplémentées ici :
les **sous-agents** (`code_subagent.go` — un rôle délégué qui travaille dans
son propre contexte), la **vérification déclenchée quand le builder a
fini** (statut `completed`, relance sur critères ouverts), le **pré-vol**
qui refuse une écriture dès son chemin (`tool_schema_order.go`), la
**réparation des noms d'outils hallucinés** (`code_alias.go`), les
**consignes du dépôt** AGENTS.md / CLAUDE.md (`code_instructions.go`),
l'**hygiène du terminal** (`chat_shell_hygiene.go`) et un **compactage**
qui garde l'état du mode Code.
## Reprises depuis l'amont après le fork
Le fork continue de suivre AJEAN et y reprend des fonctionnalités, adaptées au
conteneur : le **contrôle du navigateur** (`computer_use.go`, `computer_cdp.go`,
`browser_grid.go` — pilotage d'un Chromium en CDP, ici celui de Playwright déjà
présent dans l'image) et la **préparation des images** envoyées au modèle
(`web_upload_orient.go` — orientation EXIF et redimensionnement), les
**notifications Web Push** (`push.go`, `web_push.go`), les **tâches script** et
le dossier de scripts (`chat_scripts.go`, `tasks_script.go`, `tasks_tools.go`),
et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
`mem_store.go`, `mem_io.go`, `mem_migrate.go`, `mem_snapshots.go`,
`backup_bundle.go`). La sauvegarde vers le relais (`relay_backup.go`) n'est
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
expose à la place un export/import de paquet chiffré en fichier local.
## Moteur Strata
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
**[Strata](https://github.com/Niko1221/Strata)** (MIT), spécialisé dans
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.0 »,
`backend_moe.go`) : Loki télécharge le paquet figé publié par AJEAN sur sa
release `moe-v1.0` (sources de Strata et binaire Linux CUDA 12 compilé, avec
les modifications d'AJEAN), vérifié par SHA-256. Ni Strata ni le modèle ne sont
inclus dans l'image : ils sont téléchargés à la demande.
## Services externes
+906 -21
View File
File diff suppressed because it is too large. Load diff
+138 -8
View File
@@ -1,19 +1,149 @@
La grande nouveauté de cette version : AJEAN sait enfin voir les images.
# Loki 0.15.0
## La vision, configurable par preset
Une version entièrement consacrée à la vitesse, avec une règle tenue de bout en
bout : **ne jamais dénaturer le modèle**. Rien ne touche à la quantification
des poids, à l'échantillonnage, à la température, au budget de raisonnement ni
au contenu du contexte. Ce qui est sans perte est actif d'office ; tout ce qui
changerait ce que voit le modèle, le placement des poids ou les slots du moteur
attend une clé. Sans clé, la requête et la ligne de commande restent celles
d'avant.
Jusqu'ici rien dans l'interface ne permettait de donner des yeux à un modèle. Il fallait glisser l'option `--mmproj` à la main dans la configuration brute, et son chemin n'était même pas résolu comme celui du modèle. L'éditeur de preset gagne un champ **Vision** : tu y choisis le projecteur multimodal (fichier `mmproj`) qui accompagne le modèle, et c'est tout. Au démarrage du moteur, AJEAN le charge tout seul.
La section **Performance** du README rassemble toutes les clés, leur défaut et
leur prix.
Le champ ne liste que les projecteurs (les fichiers dont le nom contient `mmproj`), pas les modèles de plusieurs Go, pour que le choix reste lisible. Et s'il te manque un projecteur, le champ « télécharger un modèle » accepte aussi un lien vers un `mmproj` : une fois récupéré, il se place directement dans le champ Vision.
## Actif d'office, sans perte
## Les images arrivent vraiment au modèle
**La conversation n'est plus recalculée pour rien.**
Avant, une image collée dans le chat était simplement déposée comme un fichier dans le dossier de travail, à charge pour le modèle de l'ouvrir avec ses outils (ce qui ne donnait qu'un tas d'octets illisibles). Désormais, quand un projecteur est configuré, l'image part au modèle en contenu multimodal : il la voit, et peut en parler.
- **Cache de prompts dimensionné** : le moteur garde en RAM une copie exacte des
conversations qu'il quitte. Son défaut ne tenait pas une conversation de 30 à
65 k jetons à côté d'un vérificateur ou d'un sous-agent : il l'évinçait, et
tout était recalculé (30 à 80 s sur un 27B). Il est maintenant agrandi pour un
modèle tout-GPU, et le slot est effacé après chaque travail annexe pour que la
conversation revienne du cache.
- **Préfixe stable** : les rappels de Loki entrent dans l'historique tels
qu'envoyés, la relance après un 500 ne change plus les outils, la ligne MCP ne
manque plus au premier tour après un démarrage, l'ordre des trackers ne bouge
plus. Chacun de ces écarts faisait recalculer toute la boucle d'outils.
- **Compaction moins tôt** : le raisonnement que Loki ne renvoie jamais ne
compte plus dans le contexte. La compaction (qui perd de l'information)
partait vers 44-47 k au lieu de 49 k sur une fenêtre de 65 k. Son résumé n'est
plus amputé de l'état d'avancement.
- **Hybrides (Qwen3.5/3.6) sur un moteur ancien** : `--checkpoint-min-step 2048`
d'office quand la RAM le permet, pour ne plus retomber jusqu'à ~8 k jetons en
arrière à chaque reprise.
**Le matériel est mieux employé.**
- **Threads CPU** : « auto » veut dire les cœurs physiques, plus tous les
threads logiques — c'est le décodage des experts MoE sur CPU qui payait.
- **Deux GPU** : file de lancements CUDA élargie quand le pipeline entre cartes
est possible (même calcul ; seul le prompt peut gagner).
**Loki lui-même pèse moins.**
- Écrire un gros fichier ne coûte plus un temps quadratique côté Go (jusqu'à
8 s de CPU volées au décodage sur un MoE).
- L'enregistrement de la discussion ne retarde plus le premier jeton.
- Un seul `nvidia-smi` pour tous les onglets, aucun pour un onglet caché, un
seul à l'ouverture de l'éditeur de preset.
- Unraid : un `/data` ou `/models` servi par la couche FUSE est signalé, avec
le remède.
**On peut enfin mesurer.**
- Chaque réponse dit ce qu'elle a repris du cache, recalculé et accepté du
brouillon ; `GET /api/perf/summary` en fait la synthèse par nature de requête
(`lost_events`, `lost_tokens` : le cache perdu, et après quoi).
- **Bench** honnête, en tâche de fond : mode complet à profondeur réelle, tours
qui reprennent le cache, rien d'enregistré sans timings réels.
- Une sonde du gabarit de chat dit, sans rien envoyer au modèle, si le rendu
reste stable d'un message à l'autre.
**Garde-fous.** Un cache KV quantifié, `--context-shift` ou `--cache-reuse`
posés à la main sont signalés. Un mode de chargement résident voué à l'échec
(poids restés en RAM au-delà de 90 %) est refusé avec la raison en clair —
`LOAD_GUARD=off` pour passer outre ; le refus n'est plus relancé en boucle par
systemd, et un moteur Vulkan sur cartes mixtes n'est plus refusé à tort.
## En opt-in : à essayer, à mesurer
| Clé | Ce qu'elle fait |
|---|---|
| `PREWARM=on` | prépare le prochain tour pendant que tu lis (`full` : aussi la discussion qu'on ouvre) |
| `COMPACT_CONTINUATION=on` | le résumé de compaction prolonge le prompt en cache au lieu d'être calculé à froid |
| `PROJ_SNAPSHOT=on` | bloc projet figé par discussion, changements livrés à part ; date et dossier sortent du système |
| `REASONING_ECHO=on` | renvoie au moteur local la réflexion du modèle, au format entraîné |
| `SPEC=auto` / `mtp` | décodage spéculatif MTP (tête détectée dans le fichier, garde-fous VRAM) |
| `SPEC=ngram` / `mtp+ngram` | n-grammes du contexte, utiles en mode Code |
| `SIDE_SLOT=on` | second slot pour les travaux annexes (VRAM en double) |
| `SLOT_PERSIST=on` | état du slot gardé à la bascule de preset |
| `SPLIT_MODE=tensor` | parallélisme de tenseurs entre cartes (expérimental) |
| `FIT_TARGET`, `OP_OFFLOAD_MIN_BATCH`, `CUDA_GRAPH_OPT` | réglages d'expert pour cartes inégales et MoE |
| `KEEP_TURN_IMAGES=on` | **zone grise** : garde les images des outils dans l'historique |
| `NUDGE_IN_TOOL=on` | **zone grise** : rappel de budget dans le résultat d'outil |
Et deux outils qui ne font rien d'eux-mêmes :
- **Optimiseur** (`loki tune`, bouton « Optimiser… ») : cherche lots, threads,
marges et placement sur un moteur d'essai isolé ; rien n'est écrit sans un
clic, et l'application est vérifiée puis défaite si le moteur ne tient pas.
- **« Dupliquer en placement auto… »** pour un MoE aux experts placés à la
main : une copie du preset où `--fit` les répartit.
## Ordre de test conseillé
Une étape à la fois, mesurée avant la suivante :
1. **Bench complet** du preset tel quel : la référence.
2. **`PREWARM` + `COMPACT_CONTINUATION`**, une vraie session de travail, puis
`/api/perf/summary` : le cache perdu (`lost`) doit baisser.
3. **Optimiseur** sur le preset en service.
4. **MoE** : « Dupliquer en placement auto… », bench complet des deux.
5. **Mise à jour du moteur** vers la version recommandée (encart du panneau
Moteur).
6. **`SPEC=auto`** (ou `mtp`) si le modèle a une tête MTP.
## Moteur : version recommandée et retour arrière
Quand le moteur officiel est antérieur à `b10864`, le panneau Moteur dit ce
qu'une mise à jour apporte (points de reprise des hybrides, MTP rapide) et la
propose — sur clic seulement, version vérifiée sur le registre. La version
quittée est gardée : **Revenir à la version précédente** y ramène sans réseau.
Depuis `b10763`, llama-server garde par défaut la réflexion des tours passés :
un gabarit comme Qwen3.6 la rendrait vide. Avant de basculer, Loki le détecte et
propose `REASONING_PRESERVE=off` ; après, il compare le rendu de l'ancien et du
nouveau moteur et signale le premier écart.
## Ce qui a été écarté
- **`--cache-reuse`** : réutilise du cache calculé sous un autre contexte, donc
change les sorties.
- **`--context-shift`** : retire des jetons du contexte, et ne marche pas sur
les hybrides.
- **Cache KV quantifié par défaut** : `q8_0` n'est pas exact, `q4_0` perd
mesurablement, pour ~2 Gio gagnés sur un 27B hybride. Reste un choix
explicite (`KV_TYPE`).
## Autres corrections
- Une compaction en plein tour ne fait plus disparaître le rappel des pages
mémoire lues.
- Un `loki tune` tué net ne laisse plus le moteur arrêté ; lancé avec un autre
`LOKI_HOME` que l'interface (`sudo`), il refuse au lieu de charger un essai à
côté d'un moteur vivant.
- L'ancienne option « n-grammes (mod) » de l'éditeur rejoint `SPEC=ngram`, avec
une migration proposée (jamais faite d'office).
- Accès refusé : l'interface dit pourquoi et comment le lever.
## Mise à jour
```
ajean update
docker compose pull && docker compose up -d
```
Non vérifié sur cette version : le résultat final dépend d'un modèle vision et de son projecteur compatibles (Qwen2.5-VL, Gemma 3, etc.). À noter, l'image reste dans l'historique de la conversation et repart au moteur à chaque tour tant que la conversation dure.
Installation systemd (`loki install`) : l'unité du moteur gagne
`RestartPreventExitStatus=78`. `loki update` ne réécrit pas les unités ;
`sudo loki install`, ou `sudo systemctl edit loki-engine` avec cette ligne dans
`[Service]`, la met à jour.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -2,14 +2,14 @@
"FixedFileInfo": {
"FileVersion": {
"Major": 0,
"Minor": 9,
"Patch": 4,
"Minor": 15,
"Patch": 0,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 9,
"Patch": 4,
"Minor": 15,
"Patch": 0,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
"OriginalFilename": "loki.exe",
"ProductName": "Loki",
"ProductVersion": "0.9.4",
"ProductVersion": "0.15.0",
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
},
"VarFileInfo": {
@@ -34,4 +34,4 @@
"CharsetID": "04B0"
}
}
}
}
+29
View File
@@ -34,9 +34,38 @@ services:
# lancement). Ex. LOKI_MODEL=Qwen3-14B-Q4_K_M.gguf après dépôt du
# fichier dans /mnt/user/appdata/loki/models.
- LOKI_MODEL=
# Accès par NOM DE DOMAINE (reverse proxy, ex. loki.mondomaine.fr) sans
# clé de pilotage : liste ici le(s) nom(s), séparés par des virgules —
# sinon l'API répond 403 (protection contre les sites tiers / DNS
# rebinding). Inutile pour un accès par IP ou nom local.
- LOKI_TRUSTED_HOSTS=
volumes:
# PERFORMANCE — /mnt/user/… passe par la couche FUSE des partages
# Unraid (shfs) : chaque écriture de la base et chaque page d'un gros
# modèle relue depuis le disque (modèle plus gros que la RAM) traverse un
# démon en espace utilisateur. Loki le signale au démarrage (encart
# d'information, rien n'est perdu). Deux remèdes, au choix :
# - Recommandé (Unraid 6.12+) : partages en « Exclusive access ».
# Ce n'est pas une case mais un état : Settings -> Global Share
# Settings -> « Permit exclusive shares » = Yes, puis le partage
# appdata (et celui des modèles) entièrement sur UN pool — mover
# d'abord vers le pool, ensuite Secondary storage = None. La page du
# partage affiche alors « Exclusive access : Yes ». Redémarre le
# conteneur : le chemin /mnt/user reste le même, FUSE est
# court-circuité.
# - Avancé : mapper /mnt/<ton-pool>/appdata/loki/… (ex. /mnt/cache si
# ton pool s'appelle « cache »). Migration MANUELLE obligatoire :
# Compose Down, rsync -a de l'ancien dossier vers le nouveau, puis
# changer les deux lignes ci-dessous. Un /mnt/<pool> qui n'existe
# pas atterrit dans la RAM d'Unraid : données perdues au redémarrage,
# et un modèle de 80 Go peut saturer la mémoire du serveur.
# Ne change JAMAIS ce chemin sans migrer : /data apparaîtrait vide.
# /data : config, base, mémoire, workspace, modèles téléchargés par l'UI
- /mnt/user/appdata/loki/data:/data
# /models : dossier de .gguf déposés à la main (gros fichiers)
- /mnt/user/appdata/loki/models:/models
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
ulimits:
memlock: -1
restart: unless-stopped
+7
View File
@@ -22,6 +22,9 @@ services:
- LOKI_MODEL=${LOKI_MODEL:-}
- LOKI_CTX=${LOKI_CTX:-}
- LOKI_NGL=${LOKI_NGL:-}
# Accès par nom de domaine (reverse proxy) sans clé de pilotage : nom(s)
# autorisé(s), séparés par des virgules — sinon l'API répond 403.
- LOKI_TRUSTED_HOSTS=${LOKI_TRUSTED_HOSTS:-}
volumes:
# /data = LOKI_HOME : config, base bbolt, mémoire, workspace, modèles téléchargés
- ./data:/data
@@ -34,4 +37,8 @@ services:
- driver: nvidia
count: all
capabilities: ["gpu"]
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
ulimits:
memlock: -1
restart: unless-stopped
+28 -1
View File
@@ -4,12 +4,39 @@
set -eu
: "${LOKI_HOME:=/data}"
export LOKI_HOME # les sous-commandes loki doivent voir le MEME dossier
mkdir -p "$LOKI_HOME"
# Garde-fou persistance : si /data n'est pas un point de montage, tout ce que
# l'utilisateur fera (modèles, discussions, fichiers) mourra avec le conteneur.
# On le dit au premier écran du journal — l'UI l'affiche aussi en bandeau.
if ! grep -qs " $LOKI_HOME " /proc/self/mountinfo; then
echo "[entrypoint] ⚠️ $LOKI_HOME n'est pas un volume monté : les données seront PERDUES à la recréation du conteneur."
echo "[entrypoint] Ajoute « -v /chemin/hote/data:/data » (docker run) ou le mapping volumes du docker-compose."
fi
# Le chemin du moteur est imposé par l'image (llama-server précompilé de
# l'image officielle llama.cpp, dans /app) : on le (re)pose à chaque boot,
# une mise à jour de l'image ne doit pas laisser un BIN obsolète en base.
loki config set "BIN=${LOKI_ENGINE_BIN:-/app/llama-server}"
#
# SAUF si l'utilisateur a mis le moteur à jour depuis l'interface : celui-là
# vit sous $LOKI_HOME/engine/<version>/ (volume de données, il survit au
# conteneur) et c'est lui qu'il a choisi. Le reposer sur /app à chaque
# redémarrage ramenait silencieusement l'ancien moteur — un modèle qui
# chargeait la veille ne chargeait plus le lendemain, « unknown model
# architecture », sans que rien n'ait été touché. On ne le garde que s'il
# existe encore : un dossier supprimé à la main ne doit pas laisser BIN dans
# le vide.
cur=$(loki config get BIN)
case "$cur" in
"$LOKI_HOME"/engine/*/llama-server) [ -x "$cur" ] || cur="" ;;
*) cur="" ;;
esac
if [ -n "$cur" ]; then
echo "[entrypoint] moteur mis à jour conservé : $cur"
else
loki config set "BIN=${LOKI_ENGINE_BIN:-/app/llama-server}"
fi
# Les autres clés ne sont semées QUE si absentes : ce que l'utilisateur règle
# ensuite dans l'UI (modèle, contexte…) est conservé d'un redémarrage à l'autre.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,187 @@
# Dictée en temps réel, configurable selon le matériel
*Conception validée le 21 août 2026.*
## Le problème
La dictée fonctionne depuis la correction du binaire non portable (PR #18), mais deux
défauts la rendent pénible :
1. **Elle comprend mal.** Le modèle `small-q5_1` est le plus petit modèle multilingue
utilisable, et `-l auto` lui fait redeviner la langue à chaque appel.
2. **Elle est lente et muette pendant l'enregistrement.** Le texte n'arrive qu'à
l'arrêt du micro, après ~3 s de calcul pour 3,4 s d'audio.
La lenteur a une cause structurelle : chaque `POST /api/transcribe` lance un processus
`whisper-cli` qui **recharge le modèle depuis le disque**, transcrit, puis meurt. Le
chargement domine le temps de réponse. Aucun réglage ne rattrape ça, et le découpage en
tranches — indispensable pour écrire pendant qu'on parle — le rendrait catastrophique :
un rechargement complet du modèle toutes les quelques secondes.
## Ce qu'on construit
Trois choses, dans cet ordre de dépendance :
1. `whisper-server` supervisé par Loki, remplaçant les lancements de `whisper-cli`
2. Un découpage en tranches piloté par le serveur, alimenté par une WebSocket
3. Un panneau **Dictée** dans les réglages : GPU, modèle, langue, réactivité, test du micro
## 1. Le moteur
### whisper-server plutôt que whisper-cli
`whisper.cpp` fournit `whisper-server` : modèle chargé **une fois**, requêtes en HTTP.
Loki le supervise comme il supervise déjà `llama-server`.
**Démarrage paresseux, extinction sur inactivité.** Le serveur démarre au premier clic
sur le micro et s'arrête après ~10 min sans dictée. La machine cible a deux GPU dont on
veut préserver la VRAM : whisper n'a aucune raison d'occuper plusieurs centaines de Mo
pendant qu'on ne dicte pas. Le premier clic après extinction coûte le chargement du
modèle ; les suivants sont immédiats.
### Choix du GPU
`CUDA_VISIBLE_DEVICES` posé sur le processus `whisper-server`. Pas de drapeau à inventer,
pas de code à maintenir dans ggml :
- `CUDA_VISIBLE_DEVICES=1` → whisper ne voit que le second GPU
- valeur vide → CPU seul
Changer le GPU, le modèle ou la langue redémarre le processus. Rien d'autre ne bouge.
### Compilation : deux binaires, pas un
L'étape `whisperbuild` produit **deux** binaires :
- `whisper-server-cpu`, bâti comme aujourd'hui (`ubuntu:22.04`)
- `whisper-server-cuda`, bâti sur une image `nvidia/cuda:*-devel` avec `-DGGML_CUDA=ON`
La raison est dans le Dockerfile lui-même : `LLAMACPP_IMAGE` accepte la variante CPU
(`ghcr.io/ggml-org/llama.cpp:server`, ligne 11). Sur cette base, les bibliothèques CUDA
sont absentes et un binaire lié à CUDA **ne démarre pas du tout** — pas de repli
possible, l'éditeur de liens échoue avant le premier octet de code. Un unique binaire
CUDA condamnerait donc la variante CPU de l'image.
Loki choisit à l'exécution : binaire CUDA si un GPU est demandé *et* que le binaire
démarre, binaire CPU sinon. Un échec de démarrage du binaire CUDA bascule sur le CPU en
le disant, plutôt que de laisser la dictée morte.
**Les drapeaux de portabilité restent sur les deux cibles** : `GGML_NATIVE=OFF`,
`GGML_AVX512=OFF`, `GGML_AMX_*=OFF`. Le bug corrigé en PR #18 — binaire compilé pour le
processeur du runner, SIGILL ailleurs — ne doit pas revenir par la porte du build CUDA.
## 2. Le découpage
### On coupe dans les silences, jamais à heure fixe
Une tranche toutes les N secondes tombe statistiquement au milieu d'un mot une fois sur
deux. Whisper reçoit alors une demi-syllabe sans contexte, l'entrée même qui lui fait
produire du charabia. Couper au mauvais endroit dégraderait la reconnaissance au lieu de
l'améliorer.
Le **seuil de bruit est adaptatif**, pas une constante. Un seuil fixe se trompe des deux
côtés : trop haut dans une pièce silencieuse avec un micro discret, il ne coupe jamais ;
trop bas près d'un ventilateur, il coupe en permanence. Le découpeur mesure donc le
niveau des premières centaines de millisecondes comme plancher de bruit ambiant, le
réévalue en continu sur les passages calmes, et considère « silence » tout ce qui reste
proche de ce plancher. Le cas dégénéré — un plancher qui monte jusqu'à avaler la parole —
est borné par la coupure forcée.
Règle : on ferme la tranche dès qu'on observe **~400 ms de silence** *et*
qu'au moins **1,5 s de parole** est en réserve. Filet de sécurité : au-delà de **~8 s**
sans silence exploitable, on coupe quand même — quelqu'un qui parle sans respirer ne doit
pas voir le texte se figer.
Le réglage « réactivité » pilote ces bornes **ensemble** plutôt que d'exposer des curseurs
dont personne ne connaît l'interaction :
| Réglage | Réserve minimale | Coupure forcée |
|---|---|---|
| Court | 1 s | 5 s |
| Moyen (défaut) | 1,5 s | 8 s |
| Long | 3 s | 15 s |
### La logique vit en Go
Le navigateur ouvre une WebSocket et pousse le PCM au fil de l'eau **sans rien décider**.
Go accumule, détecte les silences, appelle `whisper-server`, renvoie chaque tranche.
Deux raisons. Il n'existe aucun harnais de test JavaScript dans le dépôt, alors que la CI
fait tourner `go test` — et le découpeur est précisément la pièce qui mérite des tests.
Et le navigateur redevient bête : capter, envoyer, afficher.
### Contrat de la socket
- **Client → serveur** : trames binaires, PCM 16 kHz mono 16 bits
- **Serveur → client** : JSON, `{"texte": "..."}` par tranche définitive, `{"erreur": "..."}` sinon
Le champ de saisie **ajoute à la suite, sans jamais réécrire**. Aucune révision d'un texte
déjà affiché : c'est le comportement retenu, contre l'affichage mot à mot qui se corrige
sous les yeux.
### Langue forcée par défaut
Le réglage part sur **français** plutôt que `-l auto`. Sur des tranches de quelques
secondes, la détection automatique se trompe régulièrement — c'est le mécanisme le plus
probable derrière les `ლლლ` géorgiens observés. Le mode auto reste disponible.
## 3. Le panneau Dictée
Nouveau `data-pane="dictee"` dans la modale des réglages, entre « Moteur llama.cpp » et
« Identité ». Suit le gabarit des panneaux existants.
| Réglage | Contenu |
|---|---|
| **Matériel** | GPU détectés avec VRAM libre (`/api/vram`, `/api/backends/devices`), plus « CPU seul » |
| **Modèle** | `large-v3-turbo` q5_0 (574 Mo) par défaut, `medium` q5_0 (539 Mo), `small` q5_1 (190 Mo), `large-v3` q5_0 (1,1 Go) |
| **Langue** | Français par défaut, auto et autres langues disponibles |
| **Réactivité** | Court / Moyen / Long — les bornes ci-dessus |
| **État** | whisper-server allumé ou éteint, modèle chargé, dernière erreur |
| **Test du micro** | Capte 3 s, affiche le niveau réel et la transcription, sans toucher au champ de saisie |
Les modèles restent **hors de l'image**, dans `/data/whisper/` : ils survivent aux
recréations du conteneur et ne gonflent pas le tirage. Téléchargement à la demande avec
progression et suppression, en généralisant le mécanisme mono-modèle existant.
Persistance des réglages : `putStr(bkState, …)`, le magasin clé/valeur déjà utilisé pour
le jeton Hugging Face.
## Les pannes, nommées
Chaque échec dit ce qui a échoué et ce qu'on peut y faire :
- **VRAM insuffisante au démarrage** → on le dit et on propose le repli CPU, plutôt que
d'échouer sec
- **Modèle absent** → téléchargement avec progression, pas un micro qui ne répond pas
- **Socket coupée en pleine dictée** → le texte déjà obtenu reste dans le champ,
l'utilisateur est prévenu
- **whisper-server qui meurt** → on dit **comment** il est mort (code de sortie ou signal),
pas la dernière ligne de son journal. La leçon de la PR #18 : sur une mort par signal,
cette dernière ligne ressemble à une explication sans en être une, et envoie chercher
dans la mauvaise direction.
`POST /api/transcribe` **survit** et passe désormais par `whisper-server`. C'est le chemin
de repli si la WebSocket ne s'établit pas, et ce qui rend le test du micro trivial.
## Tests
En Go, sur ce qui peut réellement casser :
- **le découpeur** — silence franc, parole ininterrompue jusqu'à la coupure forcée,
tranche trop courte rejetée, silence total, bruit de fond continu au-dessus du seuil
- **l'environnement de whisper-server** — indice GPU → `CUDA_VISIBLE_DEVICES`, chemin du
modèle, langue
- **l'aller-retour des réglages** dans `bkState`
- **l'extinction sur inactivité**
- **le garde-fou du Dockerfile**, étendu : `GGML_NATIVE=OFF` conservé sur les deux cibles,
présence des cibles `whisper-server` CPU et CUDA, `GGML_CUDA=ON` sur la seconde
## Ce que les tests ne prouveront pas
**La qualité de reconnaissance ne peut pas être vérifiée automatiquement ici** : aucun
échantillon de voix n'est disponible côté développement. Les tests établissent que le
découpage et la supervision sont corrects ; que `large-v3-turbo` comprenne mieux que
`small` reste une constatation d'usage.
C'est la raison d'être du sélecteur de modèle et du test du micro : rendre le compromis
ajustable par celui qui l'entend, au lieu de le figer dans le code sur une intuition.
+3 -1
View File
@@ -5,6 +5,7 @@ go 1.25.0
require (
codeberg.org/readeck/go-readability/v2 v2.1.2
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/caddyserver/certmagic v0.25.4
github.com/coder/websocket v1.8.15
github.com/getlantern/systray v1.2.2
@@ -13,6 +14,7 @@ require (
go.etcd.io/bbolt v1.5.0
golang.org/x/mod v0.35.0
golang.org/x/net v0.55.0
golang.org/x/sys v0.45.0
)
require (
@@ -28,6 +30,7 @@ require (
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/itlightning/dateparse v0.2.1 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@@ -45,7 +48,6 @@ require (
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
)
+67
View File
@@ -6,6 +6,8 @@ github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PF
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg=
@@ -39,8 +41,10 @@ github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
@@ -84,6 +88,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
@@ -102,24 +107,86 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+16
View File
@@ -0,0 +1,16 @@
You are in CODE MODE: an autonomous coding agent working in this discussion's folder.
Method — in this order:
1. If the task has no acceptance criteria yet, set them FIRST with the criteria tool (action=add): 2-6 short, testable statements of what DONE means. For a trivial task (one obvious change), skip criteria and just do it.
2. Explore before you change: read the relevant files (read, grep, glob, git_status). Never edit a file you have not read.
3. Implement with edit (small patches) or write (new files). Match the style of the surrounding code.
4. Prove it: run the build/tests with bash. A change that was never run is not done. For a web page or UI, start the dev server with bash_bg then use web_screenshot on its localhost URL — a headless Chromium is ALREADY installed, so NEVER install puppeteer, playwright or a browser.
5. Report briefly what changed and how you verified it.
Rules:
- PLAN ONCE. If the task needs a plan, write it to PLAN.md in ONE short write, then follow it. Between tool calls, think one or two sentences at most — NEVER restate or re-derive the plan: it is in PLAN.md and in your criteria, read them instead.
- Write each file COMPLETE in a single write call. Many small writes waste turns.
- When a criterion is done AND checked, mark it completed (criteria set). Verification starts once none is left pending; only it may mark passed.
- If a command fails, read the error and fix the cause; do not retry the same command unchanged.
- Ask the user (ask tool) only for decisions that are genuinely theirs; decide the rest yourself.
- Making the change is your job; shipping it is not. Without an explicit request, never commit, push, reset or rebase, never deploy, restart a service or replace a running binary. Read-only inspection (git_status, git_diff) is fine and encouraged.
+5
View File
@@ -0,0 +1,5 @@
You are the CODE REVIEW pass of code mode. Review the current changes (git_diff) for real defects.
Look for, in this order: correctness bugs (wrong logic, unhandled errors, races), security issues (injection, path traversal, secrets), then significant simplifications. Ignore style and taste.
For each finding: file:line, one-line problem, one-line fix. If the diff is sound, say so in one sentence. Do not modify anything.
+3
View File
@@ -0,0 +1,3 @@
You are the EXPLORATION pass of code mode. Answer questions about the codebase without modifying anything.
Use read, grep, glob and git tools to locate and understand code. Cite files as path:line. Be concise: report what you found and where, not everything you read. If the answer spans several places, list them with one line each.
+8
View File
@@ -0,0 +1,8 @@
You are the PLANNING pass of code mode. Break the user's task down BEFORE any code is written.
Produce:
1. A short restatement of the goal (one sentence).
2. Acceptance criteria via the criteria tool (action=add): 2-6 short, testable statements of what DONE means.
3. The implementation steps, ordered, with the files each step touches.
Explore the code (read, grep, glob, git_status) as needed to make the plan concrete — real file paths, real function names. Do not modify anything. End by asking the user to confirm the plan or amend it.
+12
View File
@@ -0,0 +1,12 @@
You are the VERIFICATION pass of code mode. You independently check the acceptance criteria against the actual code — you did not write it, trust nothing.
For each criterion still pending or failed:
1. Understand what it actually requires.
2. Check the real changes: git_diff, read the touched files, run the relevant build/test command with bash when applicable.
3. Record the verdict with the criteria tool: action=set, status=passed (satisfied) or failed (with a note saying exactly what is missing — actionable, one line).
Rules:
- Trivial or non-code criteria that are plainly satisfied: pass them without exploring.
- Only fail a criterion that genuinely misses its requirement; a different-but-valid implementation passes.
- Do NOT fix anything yourself. You only verify and report.
- Be fast: check the diff first, read only what the criteria require.
+57 -5
View File
@@ -44,20 +44,45 @@ func emitBuildLine(line string) {
// Sert à téer la sortie des commandes de runStep quand un sink est actif.
// (mutex : stdout et stderr d'une même commande peuvent écrire en parallèle)
type sinkWriter struct {
mu sync.Mutex
buf []byte
mu sync.Mutex
buf []byte
lastProg time.Time // dernière ligne de progression (\r) émise
}
// sinkProgressEvery : une barre de progression (tqdm, téléchargements de
// l'installeur de Strata) se redessine sur place avec \r, sans jamais finir sa
// ligne. On en montre une version toutes les 10 s : sans elle, un téléchargement
// de 76 Go laissait le journal figé pendant une heure ; toutes, elles le noieraient.
const sinkProgressEvery = 10 * time.Second
func (s *sinkWriter) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.buf = append(s.buf, p...)
for {
i := strings.IndexByte(string(s.buf), '\n')
i := strings.IndexAny(string(s.buf), "\r\n")
if i < 0 {
break
}
emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r"))
if s.buf[i] == '\n' {
emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r"))
s.buf = s.buf[i+1:]
continue
}
// \r : fin de ligne Windows (\r\n) ou barre qui se redessine. Le \n peut
// arriver dans l'écriture suivante : on attend de le savoir.
if i+1 >= len(s.buf) {
break
}
if s.buf[i+1] == '\n' {
emitBuildLine(string(s.buf[:i]))
s.buf = s.buf[i+2:]
continue
}
if prog := strings.TrimSpace(string(s.buf[:i])); prog != "" && time.Since(s.lastProg) >= sinkProgressEvery {
s.lastProg = time.Now()
emitBuildLine(prog)
}
s.buf = s.buf[i+1:]
}
return len(p), nil
@@ -134,7 +159,7 @@ func detectBuildPlan() buildPlan {
}
// Vulkan (GPU générique) — utile sur Intel/AMD sans ROCm.
if hasTool("glslc") && (isFile("/usr/lib/x86_64-linux-gnu/libvulkan.so.1") || hasTool("vulkaninfo")) {
if hasTool("glslc") && (hasVulkanLib() || hasTool("vulkaninfo")) {
p.backend = "vulkan"
p.flags = append(p.flags, "-DGGML_VULKAN=ON")
return p
@@ -457,6 +482,33 @@ func hasTool(name string) bool {
return err == nil
}
// hasVulkanLib détecte le chargeur Vulkan (libvulkan) sans supposer un unique
// chemin de distribution. Repris de l'amont AJEAN (issues #28, #29) : l'ancienne
// détection testait en dur le chemin Debian multiarch
// (/usr/lib/x86_64-linux-gnu/libvulkan.so.1), invisible sur Fedora/RHEL/Atomic où
// la bibliothèque vit dans /usr/lib64 — le plan de build retombait alors sur le
// CPU sur une machine parfaitement capable. On interroge d'abord ldconfig (la
// source de vérité du linker dynamique), puis une liste de chemins connus.
func hasVulkanLib() bool {
// ldconfig -p liste les bibliothèques du cache, tous chemins confondus.
if out, err := exec.Command("ldconfig", "-p").Output(); err == nil {
if strings.Contains(string(out), "libvulkan.so") {
return true
}
}
for _, p := range []string{
"/usr/lib/x86_64-linux-gnu/libvulkan.so.1", // Debian/Ubuntu multiarch
"/usr/lib64/libvulkan.so.1", // Fedora/RHEL/Bazzite
"/usr/lib/libvulkan.so.1", // Arch et divers
"/lib64/libvulkan.so.1",
} {
if isFile(p) {
return true
}
}
return false
}
func requireTools(tools ...string) error {
missing := missingTools(tools)
if len(missing) == 0 {
+29
View File
@@ -0,0 +1,29 @@
package loki
import (
"reflect"
"testing"
"time"
)
// Une barre de progression (\r sans \n) doit apparaître dans le journal, sans
// le noyer ; \r\n reste une fin de ligne ordinaire, même coupé entre deux écritures.
func TestSinkWriterProgression(t *testing.T) {
var got []string
setBuildSink(func(l string) { got = append(got, l) })
defer setBuildSink(nil)
s := &sinkWriter{}
s.Write([]byte("Step 5\r"))
s.Write([]byte("\n modèle : 1 / 76 GB\r modèle : 2 / 76 GB\r"))
s.Write([]byte(" modèle : 3 / 76 GB\r"))
s.lastProg = time.Now().Add(-time.Minute) // 10 s écoulées
s.Write([]byte(" modèle : 40 / 76 GB\r[ok] fini\n"))
// « 3 » finissait sa propre écriture : il attendait de savoir si un saut de
// ligne suivait, et part donc avec l'écriture suivante.
want := []string{"Step 5", "modèle : 1 / 76 GB", "modèle : 3 / 76 GB", "[ok] fini"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("lignes %q, attendu %q", got, want)
}
}
+92 -3
View File
@@ -12,10 +12,41 @@ import (
"strings"
)
// readAPIKeyErr renvoie la clé Bearer des complétions en distinguant « aucune
// clé » d'une LECTURE RATÉE — même nuance que readWebKeyErr, et pour la même
// raison : sans clé, l'endpoint est OUVERT. Confondre les deux, c'est ouvrir
// l'accès aux complétions parce que la base était momentanément verrouillée par
// une commande CLI. requireCompletionKey refuse donc plutôt que d'ouvrir.
func readAPIKeyErr() (string, error) {
b, err := getBytesErr(bkState, "api_key")
return string(b), err
}
// readAPIKey renvoie la clé Bearer de llama-server, ou "" si aucune n'est
// définie. Elle est rangée hors de la configuration pour survivre aux
// changements de preset, qui remplacent la configuration en bloc.
func readAPIKey() string { return getStr(bkState, "api_key") }
// définie (ou illisible). Elle est rangée hors de la configuration pour survivre
// aux changements de preset, qui remplacent la configuration en bloc. Réservé à
// l'affichage et aux appels internes ; toute décision d'accès passe par
// effectiveAPIKeyErr.
func readAPIKey() string { k, _ := readAPIKeyErr(); return k }
// effectiveAPIKeyErr renvoie la clé que llama-server exige RÉELLEMENT : celle
// rangée en base, sinon la clé résiduelle de config.env (repli rétro-compatible,
// cf. backend_serve.go).
//
// Une seule source pour les deux côtés du relais. Quand la garde de Loki ne
// regardait que la clé en base, un API_KEY oublié dans config.env donnait un
// endpoint « ouvert » côté Loki et un 401 côté moteur — le client voyait un
// refus sans jamais comprendre quelle clé fournir.
func effectiveAPIKeyErr() (string, error) {
k, err := readAPIKeyErr()
if err != nil {
return "", err
}
if k != "" {
return k, nil
}
return strings.TrimSpace(ReadConfig()["API_KEY"]), nil
}
// authHeader sets the Authorization: Bearer header on req when an API key is
// configured, so Loki's own internal calls (chat/web/bench/test) authenticate
@@ -61,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:
@@ -203,6 +237,61 @@ func splitArgs(s string) []string {
return out
}
// samplingKeys : les paramètres d'échantillonnage réglables par preset. Repris
// de l'amont AJEAN (v0.9.5/v0.9.6).
//
// Ils sont injectés dans CHAQUE requête de complétion (applySampling) et non
// passés à llama-server au lancement : une valeur présente dans la requête
// l'emporte de toute façon sur le défaut du serveur, donc c'est le seul endroit
// où le réglage prend vraiment effet. Sans ça, seule la température voyageait, et
// top_k/min_p/… retombaient sur les défauts de llama.cpp — rarement ceux que
// recommande le modèle (Qwen3.8 veut top_k 20 / min_p 0, pas 40 / 0.05).
//
// Correspondance clé de config → champ du corps OpenAI. Une valeur vide vaut
// « clé absente » : llama-server garde alors son propre défaut pour ce champ.
var samplingKeys = []struct {
cfg, field string
integer bool
}{
{"TEMP", "temperature", false},
{"TOP_P", "top_p", false},
{"TOP_K", "top_k", true},
{"MIN_P", "min_p", false},
{"PRESENCE_PENALTY", "presence_penalty", false},
{"REPEAT_PENALTY", "repeat_penalty", false},
}
// applySampling superpose les paramètres d'échantillonnage du preset au corps
// d'une requête de complétion. Les clés vides sont ignorées (le serveur garde son
// défaut) ; TEMP, s'il est défini, l'emporte sur la température déjà posée dans le
// payload — c'est le réglage recommandé du modèle, et l'interface n'a pas de
// curseur de température qui pourrait le contredire.
//
// Contrairement à l'amont, REASONING_EFFORT n'est PAS traité ici : loki lui
// réserve un chemin plus riche (llm_client.go + llm_effort.go — champ de haut
// niveau, kwargs du gabarit, repli quand le gabarit refuse le niveau). L'écrire
// depuis ici écraserait `chat_template_kwargs`, donc la consigne « aucune » avec.
func applySampling(payload map[string]any) { applySamplingFrom(payload, ReadConfig()) }
// applySamplingFrom est la partie pure d'applySampling : testable sans base.
func applySamplingFrom(payload map[string]any, cfg map[string]string) {
for _, s := range samplingKeys {
v := strings.TrimSpace(cfg[s.cfg])
if v == "" {
continue
}
if s.integer {
if n, err := strconv.Atoi(v); err == nil {
payload[s.field] = n
}
continue
}
if f, err := strconv.ParseFloat(v, 64); err == nil {
payload[s.field] = f
}
}
}
// Le plafond d'appels d'outils par tour (TOOL_LIMIT) et l'anti-boucle ont été
// retirés : ils coupaient surtout des tours légitimes. Le bouton stop est le
// seul frein.
+659
View File
@@ -0,0 +1,659 @@
// backend_engine_oci.go — mise à jour du moteur llama.cpp SANS reconstruire
// l'image et sans compiler : on va chercher le moteur là où l'équipe llama.cpp
// le publie déjà tout compilé — dans son image de conteneur officielle — et on
// en extrait le seul dossier qui nous intéresse, /app.
//
// Pourquoi ce détour plutôt que les releases GitHub (backend_prebuilt.go) :
// llama.cpp ne publie AUCUN binaire CUDA pour Linux dans ses releases. La seule
// distribution CUDA/Linux officielle et précompilée, ce sont ces images
// (ghcr.io/ggml-org/llama.cpp:server-cuda-b#####). C'est aussi exactement ce
// dont l'image de Loki hérite : mettre à jour par ce chemin, c'est obtenir le
// même moteur qu'une reconstruction complète de l'image, en ~170 Mo au lieu de
// 2,6 Go et sans passer par un rebuild.
//
// Ce qu'on télécharge : PAS l'image. Un manifeste OCI liste ses couches ; le
// runtime CUDA (2 Go) et les paquets système sont déjà présents dans l'image de
// Loki, donc inutiles. Seules les couches du haut portent /app (llama-server et
// ses .so, ~170 Mo). On les prend en partant du sommet et on s'arrête dès qu'on
// tient un moteur complet.
//
// Ce que ça NE remplace PAS : le runtime CUDA, lui, vient de l'image. Un
// llama.cpp compilé pour un CUDA majeur plus récent que celui de l'image ne
// démarrera pas — d'où le test à blanc (--list-devices) avant toute bascule, et
// la possibilité de revenir au moteur d'origine en un clic.
package loki
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
// ociBase est une variable et non une constante pour que les tests puissent la
// pointer sur un faux registre : tout ce qui suit se joue dans le dialogue avec
// le registre, et le vérifier en frappant ghcr.io rendrait la CI dépendante du
// réseau.
// LOKI_OCI_REGISTRY le remplace : c'est la porte de sortie pour un réseau qui
// n'atteint pas ghcr.io directement — miroir d'entreprise, cache local — sans
// quoi la mise à jour du moteur y serait tout simplement impossible.
var ociBase = "https://ghcr.io"
func init() {
if v := strings.TrimRight(strings.TrimSpace(os.Getenv("LOKI_OCI_REGISTRY")), "/"); v != "" {
if !strings.Contains(v, "://") {
v = "https://" + v
}
ociBase = v
}
}
// ociHost : l'hôte seul, tel que l'attend le paramètre `service` du jeton.
func ociHost() string {
h := strings.TrimPrefix(strings.TrimPrefix(ociBase, "https://"), "http://")
return strings.TrimSuffix(h, "/")
}
const (
ociEngineRepo = "ggml-org/llama.cpp"
// Au-delà de cette taille, une couche n'est plus une couche « applicative » :
// c'est le runtime CUDA (2 Go) ou une base système, qu'on ne veut à aucun
// prix télécharger. On descend les couches du sommet vers la base ; croiser
// une couche de cette taille signifie qu'on a dépassé /app.
ociLayerCap = 600 << 20
)
// ociLayer : une couche du manifeste (on n'a besoin que de ça).
type ociLayer struct {
MediaType string `json:"mediaType"`
Digest string `json:"digest"`
Size int64 `json:"size"`
}
// ociDoc couvre à la fois un index multi-architecture et un manifeste d'image :
// les deux arrivent sur la même URL, distingués par le champ qui est rempli.
type ociDoc struct {
MediaType string `json:"mediaType"`
Manifests []struct {
Digest string `json:"digest"`
Platform struct {
Architecture string `json:"architecture"`
OS string `json:"os"`
} `json:"platform"`
} `json:"manifests"`
Layers []ociLayer `json:"layers"`
Annotations map[string]string `json:"annotations"`
}
// ociAccept : tous les types de manifeste qu'on sait lire. Sans cet en-tête, le
// registre répond en schéma 1 (obsolète) et la lecture échoue sans expliquer.
const ociAccept = "application/vnd.oci.image.index.v1+json," +
"application/vnd.docker.distribution.manifest.list.v2+json," +
"application/vnd.oci.image.manifest.v1+json," +
"application/vnd.docker.distribution.manifest.v2+json"
// ociToken obtient un jeton de lecture ANONYME. Les images publiques de ghcr.io
// sont lisibles sans compte, mais le registre exige quand même un jeton porteur.
func ociToken(repo string) (string, error) {
u := fmt.Sprintf("%s/token?scope=repository:%s:pull&service=%s", ociBase, repo, ociHost())
resp, err := (&http.Client{Timeout: 30 * time.Second}).Get(u)
if err != nil {
return "", fmt.Errorf("registre %s injoignable : %w", ociHost(), err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("%s/token : HTTP %d", ociHost(), resp.StatusCode)
}
var out struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Token == "" {
return "", fmt.Errorf("jeton de lecture illisible")
}
return out.Token, nil
}
// ociFetch fait un GET authentifié sur le registre.
func ociFetch(url, token, accept string) (*http.Response, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if accept != "" {
req.Header.Set("Accept", accept)
}
return (&http.Client{Timeout: 0}).Do(req)
}
// ociArch traduit GOARCH vers le nom de plateforme OCI.
func ociArch() string {
if runtime.GOARCH == "arm64" {
return "arm64"
}
return "amd64"
}
// ociResolve lit le manifeste d'un tag et renvoie les couches de l'image pour
// CETTE architecture, plus le numéro de build annoncé par l'image
// (org.opencontainers.image.version, ex. « b10450 »).
func ociResolve(repo, tag string) (layers []ociLayer, version string, err error) {
token, err := ociToken(repo)
if err != nil {
return nil, "", err
}
get := func(ref string) (*ociDoc, error) {
resp, err := ociFetch(fmt.Sprintf("%s/v2/%s/manifests/%s", ociBase, repo, ref), token, ociAccept)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return nil, fmt.Errorf("version inconnue dans le registre : %s", ref)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("manifeste %s : HTTP %d", ref, resp.StatusCode)
}
var d ociDoc
if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
return nil, fmt.Errorf("manifeste %s illisible : %w", ref, err)
}
return &d, nil
}
doc, err := get(tag)
if err != nil {
return nil, "", err
}
version = doc.Annotations["org.opencontainers.image.version"]
if len(doc.Manifests) > 0 {
want := ociArch()
digest := ""
for _, m := range doc.Manifests {
if m.Platform.OS == "linux" && m.Platform.Architecture == want {
digest = m.Digest
break
}
}
if digest == "" {
return nil, "", fmt.Errorf("aucune image linux/%s pour %s", want, tag)
}
if doc, err = get(digest); err != nil {
return nil, "", err
}
if v := doc.Annotations["org.opencontainers.image.version"]; v != "" {
version = v
}
}
if len(doc.Layers) == 0 {
return nil, "", fmt.Errorf("manifeste %s sans couche", tag)
}
return doc.Layers, version, nil
}
// ---------------------------------------------------------------------------
// Variante, version et emplacement du moteur
// ---------------------------------------------------------------------------
// engineOCISupported : ce mécanisme n'a de sens que là où ces images tournent.
// Les images llama.cpp sont linux/amd64 et linux/arm64, rien d'autre.
func engineOCISupported() bool {
return runtime.GOOS == "linux" && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64")
}
// engineVariant déduit la variante d'image correspondant au moteur COURANT, en
// regardant les backends ggml posés à côté du binaire. C'est la seule source
// fiable : le conteneur ne sait pas de quelle image il a été bâti, et demander
// à l'utilisateur de retenir « server-cuda » serait un piège à erreur.
//
// Renvoie un préfixe de tag : "server-cuda", "server-vulkan", "server-intel",
// "server-musa", ou "server" (CPU) par défaut.
func engineVariant() string {
dir := ""
if bin := currentEngineBin(); bin != "" {
dir = filepath.Dir(bin)
}
if dir == "" {
return "server"
}
has := func(prefix string) bool {
m, _ := filepath.Glob(filepath.Join(dir, prefix+"*"))
return len(m) > 0
}
switch {
case has("libggml-cuda.so"):
return "server-cuda"
case has("libggml-musa.so"):
return "server-musa"
case has("libggml-sycl.so"):
return "server-intel"
case has("libggml-vulkan.so"):
return "server-vulkan"
}
return "server"
}
// currentEngineBin : le binaire réellement utilisé (BIN de la configuration,
// avec le repli sur le moteur de l'image quand BIN n'est pas encore posé).
func currentEngineBin() string {
bin := prebuiltResolveBin(ReadConfig()["BIN"])
if bin != "" && isFile(bin) {
return bin
}
return providedEngineBin()
}
// La bannière de version de llama.cpp a changé de forme plusieurs fois, et un
// binaire ancien reste parfaitement utilisable : on lit les trois formes.
//
// version: 0.1.0-dev (build 10450, commit ece963f41) ← actuelle
// version: 10450 (ece963f) ← précédente
// build: 4589 (a1b2c3d) ← plus ancienne
//
// C'est le NUMÉRO DE BUILD qui compte : c'est lui qui figure dans les tags
// d'image (server-cuda-b10450) et donc la seule chose comparable entre ce qui
// tourne et ce qui est publié.
var reEngineBuildLong = regexp.MustCompile(`build\s+(\d+),\s*commit\s+([0-9a-fA-F]+)`)
var reEngineBuildShort = regexp.MustCompile(`(?m)^\s*(?:version|build)\s*:\s*(\d+)\s*\(([0-9a-fA-F]+)\)`)
func parseEngineBuild(out string) (build int, commit string) {
for _, re := range []*regexp.Regexp{reEngineBuildLong, reEngineBuildShort} {
if m := re.FindStringSubmatch(out); m != nil {
n, _ := strconv.Atoi(m[1])
return n, m[2]
}
}
return 0, ""
}
// engineBuildOf interroge un llama-server pour son numéro de build. Renvoie
// (0, "") si le binaire ne répond pas — un moteur cassé ne doit pas empêcher
// d'en installer un autre.
//
// Borné dans le temps comme binHelp : « loki serve » le lance avant le moteur
// (points de reprise d'un hybride, voir ckptArgs), et un binaire qui reste
// coincé à l'initialisation de ses backends ne doit pas bloquer le lancement —
// passé le délai, build inconnu, donc ni avis ni drapeau.
func engineBuildOf(bin string) (int, string) {
if bin == "" || !isFile(bin) {
return 0, ""
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := hideCmd(exec.CommandContext(ctx, bin, "--version"))
cmd.WaitDelay = 2 * time.Second // un descendant qui garde la sortie ouverte ne retient pas Wait
cmd.Env = libraryPathEnv(filepath.Dir(bin))
out, _ := cmd.CombinedOutput() // --version sort parfois en code non nul
return parseEngineBuild(string(out))
}
// engineDir : racine des moteurs installés par ce mécanisme, sous LOKI_HOME —
// c'est-à-dire sur le volume /data en conteneur, donc conservés d'un
// redémarrage (et d'une mise à jour d'image) à l'autre.
func engineDir() string { return filepath.Join(LokiHome(), "engine") }
// engineOwns dit si un chemin désigne un moteur installé ici.
func engineOwns(p string) bool {
if p == "" {
return false
}
norm := func(s string) string { return strings.ToLower(filepath.ToSlash(filepath.Clean(s))) }
return strings.HasPrefix(norm(p), norm(engineDir())+"/")
}
// engineInstalled liste les moteurs présents sous engineDir(), du plus ancien
// au plus récent (ordre des numéros de build).
func engineInstalled() []string {
entries, err := os.ReadDir(engineDir())
if err != nil {
return nil
}
var tags []string
for _, e := range entries {
if e.IsDir() && isFile(filepath.Join(engineDir(), e.Name(), "llama-server")) {
tags = append(tags, e.Name())
}
}
sort.Slice(tags, func(i, j int) bool { return engineTagBuild(tags[i]) < engineTagBuild(tags[j]) })
return tags
}
var reTagBuild = regexp.MustCompile(`-b(\d+)$`)
// engineTagBuild extrait le numéro de build d'un tag (« server-cuda-b10450 » →
// 10450), 0 si le tag n'en porte pas (tag mouvant « server-cuda »).
func engineTagBuild(tag string) int {
m := reTagBuild.FindStringSubmatch(tag)
if m == nil {
return 0
}
n, _ := strconv.Atoi(m[1])
return n
}
// engineTagOf : le tag d'un moteur téléchargé ici (« server-cuda-b10450 »), ""
// pour tout autre chemin.
func engineTagOf(bin string) string {
if !engineOwns(bin) {
return ""
}
return filepath.Base(filepath.Dir(bin))
}
// engineBinFor : chemin du llama-server d'une version installée.
func engineBinFor(tag string) string {
p := filepath.Join(engineDir(), filepath.Base(tag), "llama-server")
if isFile(p) {
return p
}
return ""
}
// enginePrune supprime les versions installées autres que celles à garder.
// Chaque version pèse ~450 Mo une fois décompressée : en laisser traîner cinq
// remplit le volume /data sans rien apporter.
func enginePrune(keep map[string]bool, logf func(string)) {
for _, tag := range engineInstalled() {
if keep[tag] {
continue
}
if err := os.RemoveAll(filepath.Join(engineDir(), tag)); err == nil && logf != nil {
logf("ancienne version supprimée : " + tag)
}
}
}
// ---------------------------------------------------------------------------
// Installation
// ---------------------------------------------------------------------------
// engineOCIInstall télécharge le moteur du tag demandé et l'installe dans
// engineDir()/<tag>. Renvoie le chemin du llama-server installé.
//
// Réentrant : une version déjà installée est renvoyée telle quelle, et une
// installation interrompue laisse un dossier « .part » qui est repris de zéro.
func engineOCIInstall(tag string, logf, phasef func(string)) (string, error) {
tag = filepath.Base(strings.TrimSpace(tag)) // jamais de séparateur dans un nom de dossier
if tag == "" || tag == "." {
return "", fmt.Errorf("version du moteur non précisée")
}
if bin := engineBinFor(tag); bin != "" {
logf("déjà installé : " + tag)
return bin, nil
}
phasef("lecture du manifeste " + tag + "…")
layers, version, err := ociResolve(ociEngineRepo, tag)
if err != nil {
return "", err
}
if version != "" {
logf(fmt.Sprintf("image %s:%s — llama.cpp %s, %d couches", ociEngineRepo, tag, version, len(layers)))
}
dest := filepath.Join(engineDir(), tag)
tmp := dest + ".part"
if err := os.RemoveAll(tmp); err != nil {
return "", err
}
if err := os.MkdirAll(tmp, 0o755); err != nil {
return "", err
}
defer os.RemoveAll(tmp) // no-op après le rename final
token, err := ociToken(ociEngineRepo)
if err != nil {
return "", err
}
// On descend du SOMMET vers la base : /app est ajouté en dernier dans le
// Dockerfile amont, et les couches du bas sont le runtime CUDA et le système,
// déjà présents ici. Une couche du haut peut masquer un fichier d'une couche
// plus basse : le premier extrait gagne, comme le ferait un runtime OCI.
seen := map[string]bool{}
var links []archiveLink
for i := len(layers) - 1; i >= 0; i-- {
l := layers[i]
if l.Size > ociLayerCap {
logf(fmt.Sprintf("couche %d ignorée (%d Mo) : c'est le runtime de l'image, pas le moteur", i, l.Size/1_000_000))
break
}
part := filepath.Join(tmp, "layer.tgz")
if l.Size > 4<<20 {
phasef(fmt.Sprintf("téléchargement du moteur (%d Mo)…", l.Size/1_000_000))
}
if err := ociDownloadBlob(ociEngineRepo, l.Digest, token, part, l.Size, logf); err != nil {
return "", fmt.Errorf("téléchargement de la couche %d : %w", i, err)
}
n, err := extractAppLayer(part, tmp, seen, &links)
_ = os.Remove(part)
if err != nil {
return "", fmt.Errorf("extraction de la couche %d : %w", i, err)
}
if n > 0 {
logf(fmt.Sprintf("couche %d : %d fichiers du moteur", i, n))
}
if engineSetComplete(seen) {
break
}
}
if err := applyLinks(links); err != nil {
return "", err
}
if !seen["llama-server"] {
return "", fmt.Errorf("llama-server absent de l'image %s — variante inattendue ?", tag)
}
if err := os.RemoveAll(dest); err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", err
}
if err := os.Rename(tmp, dest); err != nil {
return "", err
}
bin := filepath.Join(dest, "llama-server")
if err := os.Chmod(bin, 0o755); err != nil {
return "", err
}
logf("moteur installé : " + bin)
return bin, nil
}
// engineSetComplete : a-t-on de quoi lancer le moteur ? Le binaire seul ne
// suffit pas — depuis que llama.cpp éclate son code en bibliothèques, l'exécutable
// tient dans une couche et ses .so dans la précédente. S'arrêter au binaire
// donnait un moteur qui meurt sur « libllama.so: cannot open shared object ».
func engineSetComplete(seen map[string]bool) bool {
if !seen["llama-server"] {
return false
}
base, llama := false, false
for name := range seen {
switch {
case strings.HasPrefix(name, "libggml-base.so"):
base = true
case strings.HasPrefix(name, "libllama.so"):
llama = true
}
}
return base && llama
}
// ociDownloadBlob télécharge une couche vers dest, avec la progression du
// téléchargement dans le journal du job.
func ociDownloadBlob(repo, digest, token, dest string, size int64, logf func(string)) error {
req, err := http.NewRequest("GET", fmt.Sprintf("%s/v2/%s/blobs/%s", ociBase, repo, digest), nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
return downloadRequest(req, dest, size, logf)
}
// extractAppLayer extrait les entrées « app/… » d'une couche (tar.gz) à PLAT
// dans dir, en ignorant celles déjà vues (une couche supérieure fait foi) et
// les marqueurs de suppression OCI (.wh.*). Renvoie le nombre de fichiers
// extraits. Les liens sont accumulés dans links : leur cible n'est pas
// forcément déjà extraite au moment où on croise l'entrée.
func extractAppLayer(archive, dir string, seen map[string]bool, links *[]archiveLink) (int, error) {
f, err := os.Open(archive)
if err != nil {
return 0, err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return 0, err
}
defer gz.Close()
count := 0
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
return count, nil
}
if err != nil {
return count, err
}
name, ok := appEntryName(h.Name)
if !ok || seen[name] {
continue
}
p := filepath.Join(dir, name)
switch h.Typeflag {
case tar.TypeReg:
w, err := os.Create(p)
if err != nil {
return count, err
}
if _, err := io.Copy(w, tr); err != nil {
w.Close()
return count, err
}
w.Close()
_ = os.Chmod(p, os.FileMode(h.Mode)&0o777)
seen[name] = true
count++
case tar.TypeSymlink, tar.TypeLink:
// Indispensable : les .so sont livrés sous leur nom versionné
// (libllama.so.0.1.0) plus deux liens (libllama.so.0, libllama.so) —
// c'est le nom court que l'éditeur de liens cherche au lancement.
target, ok := appEntryName(h.Linkname)
if !ok {
target = filepath.Base(h.Linkname) // lien relatif au sein de /app
}
*links = append(*links, archiveLink{path: p, target: target})
seen[name] = true
count++
}
}
}
// appEntryName ramène « app/libllama.so » à « libllama.so ». Renvoie ok=false
// pour tout ce qui n'est pas un fichier direct de /app : autres dossiers,
// sous-dossiers, et marqueurs de suppression OCI.
func appEntryName(raw string) (string, bool) {
p := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(raw)), "./")
rest, ok := strings.CutPrefix(p, "app/")
if !ok || rest == "" || strings.Contains(rest, "/") {
return "", false
}
if strings.HasPrefix(rest, ".wh.") {
return "", false
}
return rest, true
}
// ---------------------------------------------------------------------------
// Vérification et bascule
// ---------------------------------------------------------------------------
// engineOCILatest renvoie le dernier build publié pour la variante (numéro et
// tag figé correspondant). On lit le tag MOUVANT (« server-cuda ») et son
// annotation de version plutôt que de lister les 1000 tags du dépôt : une seule
// requête, et c'est l'amont qui dit ce qu'est « la dernière ».
func engineOCILatest(variant string) (build int, tag string, err error) {
_, version, err := ociResolve(ociEngineRepo, variant)
if err != nil {
return 0, "", err
}
if version == "" {
// Pas d'annotation : on retombe sur le tag mouvant lui-même, qui pointe
// bien sur la dernière image — on ne saura juste pas dire son numéro.
return 0, variant, nil
}
// L'annotation vaut « b10450 » : le tag figé est « server-cuda-b10450 ».
n, _ := strconv.Atoi(strings.TrimPrefix(version, "b"))
return n, variant + "-" + version, nil
}
// engineSmokeTest lance à blanc le moteur fraîchement installé — c'est là que
// se joue le seul vrai risque de ce mécanisme. La mise à jour apporte llama.cpp,
// PAS le runtime CUDA, qui reste celui de l'image : un llama.cpp compilé pour un
// CUDA plus récent ne chargera pas son backend GPU ici.
//
// Deux échecs distincts, et le second est le sournois :
//
// 1. le binaire ne démarre pas du tout (bibliothèque manquante) — bruyant ;
// 2. il démarre mais ne voit plus AUCUNE carte, alors que le moteur courant en
// voyait. Rien ne casse : le modèle se charge, tourne sur le processeur, et
// on ne s'en aperçoit qu'au débit. C'est le pire résultat possible, donc on
// le traite comme un échec de mise à jour.
//
// Ne voir aucune carte n'est un échec que si le moteur courant en voyait :
// sur une machine sans GPU, « aucune carte » est le résultat normal.
func engineSmokeTest(bin, ancien string) error {
devs, err := engineListDevices(bin)
if err != nil {
return err
}
if len(devs) > 0 {
return nil
}
if ancien == "" || ancien == bin {
return nil
}
avant, err := engineListDevices(ancien)
if err != nil || len(avant) == 0 {
return nil // le moteur courant n'en voyait pas plus : rien à reprocher au nouveau
}
return fmt.Errorf("le moteur téléchargé ne voit aucune carte graphique, alors que le moteur actuel en voit %d — "+
"il tournerait sur le processeur, beaucoup plus lentement", len(avant))
}
// engineListDevices lance `llama-server --list-devices` sur un binaire donné.
func engineListDevices(bin string) ([]map[string]any, error) {
cmd := hideCmd(exec.Command(bin, "--list-devices"))
cmd.Env = libraryPathEnv(filepath.Dir(bin))
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("le moteur ne démarre pas ici : %v\n%s", err, tailLines(string(out), 12))
}
return parseListDevices(string(out)), nil
}
// tailLines garde les n dernières lignes non vides d'une sortie.
func tailLines(s string, n int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}
@@ -0,0 +1,53 @@
package loki
// Vérification contre le VRAI registre. Ignoré par défaut (la CI ne doit pas
// dépendre du réseau ni télécharger 170 Mo) :
//
// LOKI_NET_TEST=1 go test ./internal/loki -run TestMoteurReelDepuisGhcr -v
//
// Ce test existe parce que le faux registre ne peut pas garantir une chose : que
// la DISPOSITION de l'image amont est toujours celle qu'on suppose. Si llama.cpp
// range un jour son moteur autrement — sous-dossier, couche gigantesque, autre
// jeu de bibliothèques — c'est ici qu'on le verra, et pas chez l'utilisateur.
import (
"os"
"path/filepath"
"testing"
)
func TestMoteurReelDepuisGhcr(t *testing.T) {
if os.Getenv("LOKI_NET_TEST") == "" {
t.Skip("test réseau — LOKI_NET_TEST=1 pour l'exécuter")
}
if os.Getenv("LOKI_HOME") == "" {
t.Setenv("LOKI_HOME", t.TempDir())
}
build, tag, err := engineOCILatest("server-cuda")
if err != nil {
t.Fatal(err)
}
t.Logf("dernier build publié : %d → %s", build, tag)
if build == 0 {
t.Error("numéro de build absent de l'annotation de l'image")
}
bin, err := engineOCIInstall(tag, func(s string) { t.Log(s) }, func(s string) { t.Log("▶ " + s) })
if err != nil {
t.Fatal(err)
}
dir := filepath.Dir(bin)
// Ce que l'éditeur de liens réclamera au lancement. Un seul manquant et le
// moteur meurt sur « cannot open shared object file ».
for _, n := range []string{"libllama.so", "libggml.so", "libggml-base.so", "libggml-cuda.so", "libmtmd.so"} {
if _, err := os.Stat(filepath.Join(dir, n)); err != nil {
t.Errorf("%s : %v", n, err)
}
}
// Et le binaire doit savoir dire sa version : c'est ce qui pilote la
// comparaison « installé vs publié » de tout le panneau.
if got, commit := engineBuildOf(bin); got != build {
t.Errorf("le moteur installé annonce le build %d (%s), attendu %d", got, commit, build)
}
}
+394
View File
@@ -0,0 +1,394 @@
package loki
// Mise à jour du moteur depuis l'image officielle llama.cpp.
//
// Ce qui se joue ici n'est pas « est-ce qu'on télécharge » mais « est-ce qu'on
// télécharge le MINIMUM et est-ce que ce qu'on pose est LANÇABLE ». Deux pièges
// concrets, tous deux invisibles sans test :
//
// - l'image officielle range llama-server dans une couche et ses .so dans la
// précédente ; s'arrêter au binaire donne un moteur qui meurt sur
// « libllama.so: cannot open shared object file » ;
// - la couche du dessous est le runtime CUDA (2 Go). La descendre par erreur,
// c'est transformer une mise à jour de 170 Mo en un téléchargement de 2 Go.
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// --- Faux registre OCI ------------------------------------------------------
// fauxCouche fabrique une couche tar.gz. Les entrées sont décrites par leur nom
// complet dans l'archive ; une valeur préfixée par « -> » crée un lien.
func fauxCouche(t *testing.T, entrees map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for nom, contenu := range entrees {
if cible, ok := strings.CutPrefix(contenu, "-> "); ok {
if err := tw.WriteHeader(&tar.Header{Name: nom, Typeflag: tar.TypeSymlink, Linkname: cible, Mode: 0o777}); err != nil {
t.Fatal(err)
}
continue
}
if err := tw.WriteHeader(&tar.Header{Name: nom, Typeflag: tar.TypeReg, Size: int64(len(contenu)), Mode: 0o755}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(contenu)); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// fauxRegistre sert un index multi-architecture, le manifeste amd64 et les
// blobs. `tailles` permet d'annoncer une taille différente de la taille réelle
// (c'est ce que lit le garde-fou des grosses couches). Renvoie le compteur des
// blobs réellement téléchargés.
func fauxRegistre(t *testing.T, version string, couches [][]byte, tailles []int64) (servis map[string]int) {
t.Helper()
servis = map[string]int{}
digests := make([]string, len(couches))
blobs := map[string][]byte{}
for i, c := range couches {
sum := sha256.Sum256(c)
digests[i] = "sha256:" + hex.EncodeToString(sum[:])
blobs[digests[i]] = c
}
manifeste := map[string]any{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"layers": func() []map[string]any {
var out []map[string]any
for i := range couches {
taille := int64(len(couches[i]))
if tailles != nil && tailles[i] > 0 {
taille = tailles[i]
}
out = append(out, map[string]any{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": digests[i], "size": taille,
})
}
return out
}(),
}
manBytes, _ := json.Marshal(manifeste)
manSum := sha256.Sum256(manBytes)
manDigest := "sha256:" + hex.EncodeToString(manSum[:])
index := map[string]any{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": []map[string]any{
// L'architecture qui ne nous concerne pas vient EN PREMIER : choisir
// « le premier manifeste » au lieu de filtrer sur la plateforme
// installerait un moteur d'une autre architecture.
{"digest": "sha256:mauvaise", "platform": map[string]string{"os": "linux", "architecture": "ppc64le"}},
{"digest": manDigest, "platform": map[string]string{"os": "linux", "architecture": ociArch()}},
},
"annotations": map[string]string{"org.opencontainers.image.version": version},
}
indexBytes, _ := json.Marshal(index)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/token"):
_, _ = w.Write([]byte(`{"token":"jeton-de-test"}`))
case strings.Contains(r.URL.Path, "/manifests/"):
if r.Header.Get("Authorization") != "Bearer jeton-de-test" {
w.WriteHeader(401)
return
}
ref := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
if ref == manDigest {
_, _ = w.Write(manBytes)
return
}
_, _ = w.Write(indexBytes)
case strings.Contains(r.URL.Path, "/blobs/"):
ref := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
b, ok := blobs[ref]
if !ok {
w.WriteHeader(404)
return
}
servis[ref]++
_, _ = w.Write(b)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(srv.Close)
ancien := ociBase
ociBase = srv.URL
t.Cleanup(func() { ociBase = ancien })
return servis
}
// --- Tests ------------------------------------------------------------------
// Le cas réel : trois couches utiles au-dessus d'un runtime de 2 Go. On doit
// prendre le binaire ET les bibliothèques, résoudre les liens de version, et
// ne JAMAIS descendre jusqu'au runtime.
func TestInstallationMoteurDepuisImage(t *testing.T) {
testHome(t)
couches := [][]byte{
fauxCouche(t, map[string]string{"usr/local/cuda/lib64/libcudart.so": "runtime CUDA"}), // 2 Go annoncés
fauxCouche(t, map[string]string{
"app/libggml-base.so": "-> libggml-base.so.0",
"app/libggml-base.so.0": "-> libggml-base.so.0.20.0",
"app/libggml-base.so.0.20.0": "binaire ELF",
"app/libllama.so": "-> libllama.so.0",
"app/libllama.so.0": "-> libllama.so.0.1.0",
"app/libllama.so.0.1.0": "binaire ELF",
"app/libggml-cuda.so": "binaire ELF",
}),
fauxCouche(t, map[string]string{"app/llama-server": "#!/bin/true\n", "app/llama": "#!/bin/true\n"}),
fauxCouche(t, map[string]string{"etc/passwd": "sans rapport"}),
}
servis := fauxRegistre(t, "b10450", couches, []int64{2 << 30, 0, 0, 0})
bin, err := engineOCIInstall("server-cuda-b10450", func(string) {}, func(string) {})
if err != nil {
t.Fatal(err)
}
if veut := filepath.Join(engineDir(), "server-cuda-b10450", "llama-server"); bin != veut {
t.Errorf("binaire installé en %s, attendu %s", bin, veut)
}
// La couche du runtime CUDA ne doit pas avoir été demandée : c'est tout
// l'intérêt (170 Mo au lieu de 2,6 Go).
sum := sha256.Sum256(couches[0])
if n := servis["sha256:"+hex.EncodeToString(sum[:])]; n != 0 {
t.Errorf("la couche du runtime CUDA a été téléchargée %d fois", n)
}
dir := filepath.Dir(bin)
for _, nom := range []string{"llama-server", "llama", "libggml-cuda.so", "libggml-base.so.0.20.0", "libllama.so.0.1.0"} {
if !isFile(filepath.Join(dir, nom)) {
t.Errorf("%s manquant", nom)
}
}
// Les noms courts sont ceux que l'éditeur de liens cherche au lancement :
// sans eux le moteur ne démarre pas, alors que tout « a l'air » installé.
for _, lien := range []string{"libggml-base.so", "libggml-base.so.0", "libllama.so", "libllama.so.0"} {
p := filepath.Join(dir, lien)
if _, err := os.Stat(p); err != nil { // Stat suit le lien : vérifie aussi qu'il ne pend pas
t.Errorf("%s : %v", lien, err)
}
}
// /app à plat, et rien d'autre que /app.
if isDir(filepath.Join(dir, "etc")) || isDir(filepath.Join(dir, "usr")) {
t.Error("des fichiers hors /app ont été extraits")
}
// Windows n'a pas de bit exécutable : Stat y renvoie 0666/0444 quoi qu'il
// arrive, et l'assertion échouait toujours — alors que l'installation, elle,
// est correcte (le bit est bien posé côté Linux, là où le moteur tourne).
if st, err := os.Stat(bin); err != nil || (runtime.GOOS != "windows" && st.Mode()&0o100 == 0) {
t.Errorf("llama-server non exécutable : %v", err)
}
// Réinstaller la même version ne redemande RIEN au registre : le job de mise
// à jour repasse par là à chaque vérification.
total := func() int {
n := 0
for _, v := range servis {
n += v
}
return n
}
avant := total()
if _, err := engineOCIInstall("server-cuda-b10450", func(string) {}, func(string) {}); err != nil {
t.Fatal(err)
}
if total() != avant {
t.Errorf("une version déjà installée a été re-téléchargée (%d → %d couches)", avant, total())
}
// Et la version se retrouve dans la liste des moteurs installés.
if got := engineInstalled(); len(got) != 1 || got[0] != "server-cuda-b10450" {
t.Errorf("versions installées = %v", got)
}
if engineTagBuild("server-cuda-b10450") != 10450 {
t.Error("numéro de build mal extrait du tag")
}
if !engineOwns(bin) {
t.Error("le binaire installé n'est pas reconnu comme géré par Loki")
}
}
// Une variante dont l'image ne contient pas de llama-server (mauvais tag, image
// « full » sans serveur) doit échouer CLAIREMENT, et ne rien laisser derrière
// elle qui ressemblerait à un moteur installé.
func TestInstallationRefuseUneImageSansMoteur(t *testing.T) {
testHome(t)
fauxRegistre(t, "b10450", [][]byte{
fauxCouche(t, map[string]string{"app/README": "pas de moteur ici"}),
}, nil)
if _, err := engineOCIInstall("server-b1", func(string) {}, func(string) {}); err == nil {
t.Fatal("installation acceptée sans llama-server")
}
if got := engineInstalled(); len(got) != 0 {
t.Errorf("versions installées après échec = %v", got)
}
}
// engineOCILatest lit le numéro de build dans l'annotation de l'image, et en
// déduit le tag figé — c'est lui qu'on installe, pour que « mis à jour vers »
// désigne une version précise et reproductible plutôt qu'un tag mouvant.
func TestDerniereVersionPubliee(t *testing.T) {
fauxRegistre(t, "b10450", [][]byte{fauxCouche(t, map[string]string{"app/llama-server": "x"})}, nil)
build, tag, err := engineOCILatest("server-cuda")
if err != nil {
t.Fatal(err)
}
if build != 10450 || tag != "server-cuda-b10450" {
t.Errorf("build=%d tag=%q", build, tag)
}
}
// La bannière du moteur est la seule source de vérité sur la version qui tourne
// vraiment — le tag de l'image, lui, ment dès qu'on a basculé de moteur.
func TestLectureVersionMoteur(t *testing.T) {
for _, c := range []struct {
nom, sortie string
build int
commit string
}{
// Relevée sur le binaire réel de l'image server-cuda-b10450.
{"forme actuelle", "version: 0.1.0-dev (build 10450, commit ece963f41)\nbuilt with GNU 14.2.0 for Linux x86_64\n", 10450, "ece963f41"},
{"forme précédente", "version: 10450 (ece963f)\nbuilt with cc\n", 10450, "ece963f"},
{"forme ancienne", "build: 4589 (a1b2c3d)\n", 4589, "a1b2c3d"},
{"bannière de démarrage", "ggml_cuda_init: found 1 device\nversion: 9001 (deadbee)\n", 9001, "deadbee"},
{"rien d'exploitable", "usage: llama-server [options]\n", 0, ""},
} {
t.Run(c.nom, func(t *testing.T) {
build, commit := parseEngineBuild(c.sortie)
if build != c.build || commit != c.commit {
t.Errorf("= (%d, %q), attendu (%d, %q)", build, commit, c.build, c.commit)
}
})
}
}
// Le filtre des entrées d'archive : on ne veut QUE les fichiers directs de /app.
// Un sous-dossier ou un marqueur de suppression OCI recopié à plat produirait
// des fichiers parasites à côté du binaire.
func TestFiltreDesEntreesApp(t *testing.T) {
for _, c := range []struct {
entree, veut string
ok bool
}{
{"app/llama-server", "llama-server", true},
{"./app/libllama.so", "libllama.so", true},
{"app/", "", false},
{"app/tools/quantize", "", false},
{"app/.wh.libggml-cuda.so", "", false},
{"usr/local/cuda/lib64/libcudart.so", "", false},
{"apparence/piege", "", false},
} {
got, ok := appEntryName(c.entree)
if ok != c.ok || got != c.veut {
t.Errorf("%q → (%q, %v), attendu (%q, %v)", c.entree, got, ok, c.veut, c.ok)
}
}
}
// Le binaire seul ne fait pas un moteur : c'est la règle d'arrêt de la descente
// des couches.
func TestMoteurCompletExigeLesBibliotheques(t *testing.T) {
vu := func(noms ...string) map[string]bool {
m := map[string]bool{}
for _, n := range noms {
m[n] = true
}
return m
}
if engineSetComplete(vu("llama-server")) {
t.Error("binaire seul accepté comme moteur complet")
}
if engineSetComplete(vu("llama-server", "libggml-base.so.0.20.0")) {
t.Error("moteur sans libllama accepté")
}
if !engineSetComplete(vu("llama-server", "libggml-base.so.0.20.0", "libllama.so.0.1.0")) {
t.Error("moteur complet refusé")
}
}
// La variante se déduit des backends posés à côté du binaire : c'est ce qui
// évite de proposer une image CUDA à une installation Vulkan (et l'inverse).
func TestVarianteDeduiteDesBackends(t *testing.T) {
for _, c := range []struct{ backend, veut string }{
{"libggml-cuda.so", "server-cuda"},
{"libggml-vulkan.so", "server-vulkan"},
{"libggml-sycl.so", "server-intel"},
{"libggml-musa.so", "server-musa"},
{"", "server"},
} {
t.Run(c.veut, func(t *testing.T) {
home := testHome(t)
dir := filepath.Join(home, "faux-moteur")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(dir, "llama-server")
if err := os.WriteFile(bin, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
if c.backend != "" {
if err := os.WriteFile(filepath.Join(dir, c.backend), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
if err := SetConfigKey("BIN", bin); err != nil {
t.Fatal(err)
}
if got := engineVariant(); got != c.veut {
t.Errorf("variante = %q, attendu %q", got, c.veut)
}
})
}
}
// Le ménage garde la version demandée et efface les autres : sans lui, chaque
// mise à jour laisse ~450 Mo sur le volume de données.
func TestMenageDesAnciennesVersions(t *testing.T) {
testHome(t)
for _, tag := range []string{"server-cuda-b1", "server-cuda-b2", "server-cuda-b3"} {
d := filepath.Join(engineDir(), tag)
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "llama-server"), []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
}
enginePrune(map[string]bool{"server-cuda-b3": true}, nil)
got := engineInstalled()
if fmt.Sprint(got) != "[server-cuda-b3]" {
t.Errorf("après ménage : %v", got)
}
}
+132
View File
@@ -0,0 +1,132 @@
package loki
// backend_external.go — presets « externes » : au lieu de lancer un llama-server
// local, un tel preset route le chat vers une API OpenAI-compatible distante
// (OpenAI, Groq, OpenRouter, un autre Loki, un vLLM sur une autre machine…).
// Un preset externe se reconnaît à sa clé EXTERNAL=1 ; il porte l'URL, le nom du
// modèle et la clé d'accès.
//
// Choix d'archi, repris d'AJEAN : l'externe est un PRESET COMME UN AUTRE, un
// fichier .env dans presetsDir. Toute la mécanique existante s'applique sans
// rien changer — liste, nom d'affichage, empreinte d'activité, bascule, prompt
// système par preset, réglages d'échantillonnage. La différence ne vit qu'à
// DEUX endroits : au moment de l'inférence (resolveChatEndpoint) et au moment
// de la bascule (aucun moteur à redémarrer).
//
// Ce qui ne s'applique pas, et c'est voulu : un preset externe n'a ni MODEL, ni
// NGL, ni moteur. La machine distante décide de tout ça. CTX, en revanche,
// reste utile — c'est lui qui pilote la jauge de contexte et le seuil de
// compaction côté Loki.
import (
"fmt"
"strings"
)
const (
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
extKeyVision = "EXTERNAL_VISION" // "1" = le modèle distant accepte les images
)
// chatEndpoint : où partent les appels /v1/chat/completions de ce tour.
type chatEndpoint struct {
URL string // URL complète des complétions
Model string // nom de modèle envoyé dans le payload
Key string // Bearer ("" = aucun)
External bool // true = API distante (pas le llama-server local)
}
// isExternalConfig indique si une configuration décrit un endpoint externe.
func isExternalConfig(cfg map[string]string) bool {
return strings.TrimSpace(cfg[extKeyFlag]) == "1"
}
// externalActive : le preset actif est-il un endpoint externe ?
func externalActive() bool { return isExternalConfig(ReadConfig()) }
// externalVisionActive : le preset actif est externe ET déclaré multimodal. Pas
// de MMPROJ ni de sonde /props à consulter côté distant : seule la case du
// preset dit si le modèle accepte les images.
func externalVisionActive() bool {
cfg := ReadConfig()
return isExternalConfig(cfg) && strings.TrimSpace(cfg[extKeyVision]) == "1"
}
// completionsURL normalise l'URL saisie en une URL de complétions complète.
// Accepte les trois formes qu'on colle en pratique :
//
// - une URL déjà complète (…/chat/completions) — laissée telle quelle ;
// - une base OpenAI (…/v1, …/openai/v1, …/api/v1) — on ajoute /chat/completions ;
// - autre chose — on suppose une racine et on ajoute /v1/chat/completions.
//
// Exiger la forme exacte serait une source d'échecs muets : la page d'un
// fournisseur donne tantôt « https://api.groq.com/openai/v1 », tantôt l'URL
// complète, et se tromper ne produit qu'un 404 illisible.
func completionsURL(raw string) string {
u := strings.TrimRight(strings.TrimSpace(raw), "/")
if u == "" {
return ""
}
if strings.HasSuffix(u, "/chat/completions") {
return u
}
if strings.HasSuffix(u, "/v1") {
return u + "/chat/completions"
}
return u + "/v1/chat/completions"
}
// resolveChatEndpoint décide où envoyer les complétions : l'API externe si le
// preset actif en est un, sinon le llama-server local.
func resolveChatEndpoint() chatEndpoint {
cfg := ReadConfig()
if isExternalConfig(cfg) {
return chatEndpoint{
URL: completionsURL(cfg[extKeyURL]),
Model: strings.TrimSpace(cfg[extKeyModel]),
Key: strings.TrimSpace(cfg[extKeyToken]),
External: true,
}
}
return chatEndpoint{
URL: fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()),
Model: "loki",
Key: readAPIKey(),
}
}
// auth pose l'en-tête d'autorisation de CET endpoint. Indispensable de le tenir
// ici plutôt que d'appeler authHeader : celui-ci envoie la clé du serveur LOCAL,
// et l'expédier à api.openai.com serait fuiter un secret chez un tiers en même
// temps qu'un 401 garanti.
func (e chatEndpoint) auth(set func(k, v string)) {
if e.Key != "" {
set("Authorization", "Bearer "+e.Key)
}
}
// externalPresetContent construit le corps .env d'un preset externe (hors ligne
// « # NAME= », ajoutée par SavePreset). Une clé vide n'est pas écrite. `ctx` va
// dans la clé CTX standard — elle pilote la jauge de contexte et le seuil de
// compaction, exactement comme pour un preset local. vision = le modèle distant
// accepte les images (EXTERNAL_VISION=1).
func externalPresetContent(url, model, key, ctx string, vision bool) string {
m := map[string]string{
extKeyFlag: "1",
extKeyURL: strings.TrimSpace(url),
extKeyModel: strings.TrimSpace(model),
}
if k := strings.TrimSpace(key); k != "" {
m[extKeyToken] = k
}
if vision {
m[extKeyVision] = "1"
}
if c := strings.TrimSpace(ctx); c != "" {
m["CTX"] = c
}
return formatEnv(m)
}
@@ -0,0 +1,43 @@
package loki
import "testing"
// Preset externe actif : `loki serve` n'a aucun moteur local à lancer. Il
// sortait en erreur « MODEL non défini », que systemd (Restart=on-failure)
// relançait en boucle (amont #95).
func TestServeExitsCleanlyForExternalPreset(t *testing.T) {
testHome(t)
if err := WriteConfig(map[string]string{extKeyFlag: "1", "PORT": "8080"}); err != nil {
t.Fatal(err)
}
if err := cmdServe(nil); err != nil {
t.Fatalf("loki serve sur un preset externe : %v", err)
}
}
// Le pré-vol refusait `loki start` / `loki restart` sur un preset externe,
// en invitant à réinstaller llama.cpp.
func TestPreflightAcceptsExternalPreset(t *testing.T) {
testHome(t)
if err := WriteConfig(map[string]string{extKeyFlag: "1"}); err != nil {
t.Fatal(err)
}
if err := preflightEngine(); err != nil {
t.Fatalf("pré-vol refusé sur un preset externe : %v", err)
}
}
// Strata : le pré-vol vérifie sa configuration à lui, pas BIN/MODEL.
func TestPreflightStrataWithoutConfig(t *testing.T) {
testHome(t)
if err := WriteConfig(map[string]string{"ENGINE": "strata"}); err != nil {
t.Fatal(err)
}
err := preflightEngine()
if err == nil {
t.Fatal("un preset Strata sans STRATA_CONFIG devrait être refusé")
}
if want := "STRATA_CONFIG"; !contains(err.Error(), want) {
t.Fatalf("message attendu sur %s : %v", want, err)
}
}
+245
View File
@@ -0,0 +1,245 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Les trois formes d'URL qu'on colle en pratique. Exiger la forme exacte serait
// une source d'échecs muets : la page d'un fournisseur donne tantôt la base,
// tantôt l'URL complète, et se tromper ne produit qu'un 404 illisible.
func TestNormalisationDeLURLDeComplétions(t *testing.T) {
cas := map[string]string{
"https://api.openai.com/v1": "https://api.openai.com/v1/chat/completions",
"https://api.groq.com/openai/v1": "https://api.groq.com/openai/v1/chat/completions",
"https://api.openai.com/v1/chat/completions": "https://api.openai.com/v1/chat/completions",
"https://api.openai.com/v1/": "https://api.openai.com/v1/chat/completions",
"http://192.168.1.20:8080": "http://192.168.1.20:8080/v1/chat/completions",
"": "",
}
for in, want := range cas {
if got := completionsURL(in); got != want {
t.Errorf("completionsURL(%q) = %q, attendu %q", in, got, want)
}
}
}
// Un preset externe actif détourne les complétions ; sans lui, on reste sur le
// llama-server local avec la clé LOCALE.
func TestEndpointSuitLePresetActif(t *testing.T) {
testHome(t)
if err := SetConfigKey("PORT", "9999"); err != nil {
t.Fatal(err)
}
ep := resolveChatEndpoint()
if ep.External {
t.Fatal("aucun preset externe : l'endpoint doit rester local")
}
if ep.URL != "http://localhost:9999/v1/chat/completions" {
t.Fatalf("URL locale = %q", ep.URL)
}
if ep.Model != "loki" {
t.Fatalf("modèle local = %q, attendu loki", ep.Model)
}
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536", false))); err != nil {
t.Fatal(err)
}
ep = resolveChatEndpoint()
if !ep.External {
t.Fatal("EXTERNAL=1 non reconnu")
}
if ep.URL != "https://api.groq.com/openai/v1/chat/completions" {
t.Fatalf("URL externe = %q", ep.URL)
}
if ep.Model != "llama-3.3-70b" || ep.Key != "sk-secret" {
t.Fatalf("modèle/clé externes = %q / %q", ep.Model, ep.Key)
}
// CTX passe par la clé standard : c'est elle qui pilote la jauge de contexte
// et le seuil de compaction, exactement comme pour un preset local.
if got := ReadConfig()["CTX"]; got != "65536" {
t.Fatalf("CTX = %q, attendu 65536", got)
}
}
// La clé du serveur LOCAL ne doit jamais partir chez un tiers : ce serait fuiter
// un secret en même temps qu'un 401 garanti.
func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) {
testHome(t)
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.openai.com/v1", "gpt-4o-mini", "", "", false))); err != nil {
t.Fatal(err)
}
// Une clé d'API locale existe et n'a rien à faire dans la requête sortante.
if err := SetConfigKey("API_KEY", "sk-loki-locale"); err != nil {
t.Fatal(err)
}
ep := resolveChatEndpoint()
if ep.Key != "" {
t.Fatalf("clé envoyée à l'API externe = %q — le preset n'en déclare aucune", ep.Key)
}
got := map[string]string{}
ep.auth(func(k, v string) { got[k] = v })
if len(got) != 0 {
t.Fatalf("en-tête posé sans clé de preset : %v", got)
}
}
// Un preset externe se reconnaît, un preset local ne se prend pas pour un externe.
func TestReconnaissanceDunPresetExterne(t *testing.T) {
if !isExternalConfig(map[string]string{extKeyFlag: "1"}) {
t.Error("EXTERNAL=1 non reconnu")
}
for _, v := range []string{"", "0", "off", "false"} {
if isExternalConfig(map[string]string{extKeyFlag: v}) {
t.Errorf("EXTERNAL=%q pris pour un preset externe", v)
}
}
if isExternalConfig(parseEnv("MODEL=\"m.gguf\"\nCTX=4096\n")) {
t.Error("un preset local est pris pour un externe")
}
}
// La route de test rend un message LISIBLE plutôt que le JSON brut : c'est tout
// l'intérêt de tester avant d'enregistrer.
func TestRouteDeTestRemonteLErreurDeLAPI(t *testing.T) {
testHome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer sk-bonne" {
w.WriteHeader(401)
_, _ = w.Write([]byte(`{"error":{"message":"Incorrect API key provided"}}`))
return
}
sendJSON(w, 200, map[string]any{"choices": []any{}})
}))
t.Cleanup(srv.Close)
post := func(body string) map[string]any {
w := httptest.NewRecorder()
handlePresetExternalTest(w, httptest.NewRequest("POST", "/api/preset/external/test", strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return out
}
out := post(`{"url":"` + srv.URL + `","model":"m","key":"sk-mauvaise","keyTouched":true}`)
if out["ok"] == true {
t.Fatal("clé refusée annoncée comme valide")
}
if msg, _ := out["error"].(string); msg != "Incorrect API key provided" {
t.Fatalf("message d'erreur = %q, attendu celui de l'API et non le JSON brut", msg)
}
if out = post(`{"url":"` + srv.URL + `","model":"m","key":"sk-bonne","keyTouched":true}`); out["ok"] != true {
t.Fatalf("clé valide refusée : %v", out)
}
}
// Rééditer un preset sans toucher au champ clé ne doit PAS l'effacer : la modale
// ne la reçoit jamais en clair, donc un champ vide n'est pas une demande
// d'effacement.
func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
testHome(t)
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", "", false))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save",
strings.NewReader(`{"id":"`+id+`","name":"Groq","url":"https://api.groq.com/openai/v1","model":"llama-3.3","key":""}`)))
if w.Code != 200 {
t.Fatalf("code %d : %s", w.Code, w.Body.String())
}
content, err := ReadPreset(id)
if err != nil {
t.Fatal(err)
}
cfg := parseEnv(content)
if cfg[extKeyToken] != "sk-gardee" {
t.Fatalf("clé = %q — effacée par une édition qui n'y touchait pas", cfg[extKeyToken])
}
if cfg[extKeyModel] != "llama-3.3" {
t.Fatalf("modèle non mis à jour : %q", cfg[extKeyModel])
}
// Et la clé ne ressort jamais en clair par la route de lecture.
w = httptest.NewRecorder()
handlePresetExternal(w, httptest.NewRequest("GET", "/api/preset/external?id="+id, nil))
if strings.Contains(w.Body.String(), "sk-gardee") {
t.Fatalf("la clé est renvoyée en clair : %s", w.Body.String())
}
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
if out["hasKey"] != true {
t.Fatal("hasKey doit signaler la présence d'une clé")
}
}
// Un preset externe demande explicitement d'écraser la clé par une vide.
func TestEffacementExpliciteDeLaCle(t *testing.T) {
testHome(t)
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", "", false))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save",
strings.NewReader(`{"id":"`+id+`","name":"Local distant","url":"http://192.168.1.20:8080","model":"qwen","key":"","keyTouched":true}`)))
if w.Code != 200 {
t.Fatalf("code %d : %s", w.Code, w.Body.String())
}
content, _ := ReadPreset(id)
if k := parseEnv(content)[extKeyToken]; k != "" {
t.Fatalf("clé = %q, attendu effacée (keyTouched avec champ vide)", k)
}
}
// Le résumé de compactage n'envoie pas chat_template_kwargs (propre à
// llama.cpp) à une API distante : une API stricte répond 400 et la compaction
// échouait à chaque fois.
func TestCompactageExterneSansKwargsLlamaCpp(t *testing.T) {
testHome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["chat_template_kwargs"]; ok {
w.WriteHeader(400)
_, _ = w.Write([]byte(`{"error":{"message":"Unrecognized request argument supplied: chat_template_kwargs"}}`))
return
}
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé"}}}})
}))
t.Cleanup(srv.Close)
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "gpt-4o-mini", "", "", false))); err != nil {
t.Fatal(err)
}
if _, err := summarizeTranscript(context.Background(), "user: bonjour\nassistant: salut"); err != nil {
t.Fatalf("compactage refusé par l'API distante : %v", err)
}
}
// Vision et benchmark d'un preset externe : la case du preset décide, et le
// bench (qui mesure le moteur local) refuse de tourner.
func TestPresetExterneVisionEtBench(t *testing.T) {
testHome(t)
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", true))); err != nil {
t.Fatal(err)
}
if !visionEnabled() || !engineSeesImages() {
t.Error("preset externe déclaré multimodal : la vision devrait être active")
}
if _, err := runBench(context.Background(), benchOpts{Prompt: 10, Predict: 10}, nil); err == nil {
t.Error("benchmark lancé sur un preset externe")
}
if got := chatModelName(); got != "gpt-4o" {
t.Errorf("badge de modèle = %q, attendu le modèle distant", got)
}
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", false))); err != nil {
t.Fatal(err)
}
if visionEnabled() {
t.Error("preset externe texte seul : la vision devrait être coupée")
}
}
+624
View File
@@ -0,0 +1,624 @@
package loki
import (
"bufio"
"encoding/binary"
"errors"
"fmt"
"io"
"math"
"math/bits"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
)
// Lecteur des métadonnées GGUF. Plusieurs réglages du moteur dépendent de ce que
// le fichier contient VRAIMENT : un modèle qui embarque une tête MTP (prédiction
// de plusieurs jetons, vérifiée exactement par le modèle cible), le nombre de
// têtes KV par couche pour estimer le cache, une architecture hybride à état
// récurrent. Jusqu'ici Loki ne le savait qu'à travers le nom du fichier, ce qui
// ne prouve rien.
//
// On ne lit que l'en-tête, la section clé/valeur et la table des tenseurs : les
// poids eux-mêmes ne sont jamais touchés, quelques Mo au plus pour un modèle de
// 30 Go. Le fichier peut être en cours de téléchargement, tronqué, ou tout
// simplement pas un GGUF : chaque longueur lue est bornée par ce qui reste du
// fichier, et la moindre incohérence donne une erreur, jamais un plantage ni une
// allocation géante. Une erreur veut dire « on ne sait pas » : l'appelant reste
// sur le comportement prudent.
// GGUFInfo résume ce que Loki retient d'un modèle GGUF. Les champs absents du
// fichier restent à zéro.
type GGUFInfo struct {
Version int // version du format (1 à 3)
Arch string // general.architecture
BlockCount int // <arch>.block_count
NextN int // <arch>.nextn_predict_layers (couches MTP annoncées)
ContextLength int // <arch>.context_length (contexte d'entraînement)
ExpertCount int // <arch>.expert_count (0 = modèle dense)
KeyLen int // <arch>.attention.key_length
ValLen int // <arch>.attention.value_length
// HeadCountKV : valeur unique, ou maximum sur les couches quand le fichier
// donne un tableau par couche (HeadCountKVLayers, nil sinon). Sur un hybride,
// les couches récurrentes y valent 0.
HeadCountKV int
HeadCountKVLayers []int
FullAttnInterval int // <arch>.full_attention_interval, si présent
Hybrid bool // au moins une clé <arch>.ssm.* : couches à état récurrent
// Repli de llama.cpp quand key_length / value_length manquent : n_embd / n_head.
EmbeddingLength int // <arch>.embedding_length
HeadCount int // <arch>.attention.head_count (valeur unique seulement)
// Taille de l'état récurrent par couche (llama_hparams::n_embd_r / n_embd_s),
// pour estimer ce que pèse un état sauvegardé d'un modèle hybride.
SSMConv int // <arch>.ssm.conv_kernel
SSMInner int // <arch>.ssm.inner_size
SSMState int // <arch>.ssm.state_size
SSMGroups int // <arch>.ssm.group_count
// HasNextNTensor : le tenseur blk.{BlockCount-1}.nextn.eh_proj.weight existe
// dans l'une des tranches. C'est le test de llama.cpp lui-même pour reconnaître
// une tête MTP : la clé nextn_predict_layers seule ne suffit pas, certains GGUF
// la gardent alors que les tenseurs ont été retirés (tête publiée à part).
HasNextNTensor bool
TensorCount int // total sur toutes les tranches
}
// Bornes de lecture. Elles sont larges devant les vrais modèles (une poignée de
// milliers de tenseurs, une cinquantaine de clés hors vocabulaire) et servent
// seulement à refuser un fichier aberrant avant d'allouer quoi que ce soit.
const (
ggufMaxString = 64 << 20 // modèle de chat le plus long connu : quelques dizaines de Ko
ggufMaxKeyLen = 64 << 10
ggufMaxTensorName = 4096 // GGML_MAX_NAME vaut 64
ggufMaxKV = 1 << 20
ggufMaxTensors = 1 << 22
ggufMaxDims = 8 // GGML_MAX_DIMS vaut 4
ggufMaxArrayDepth = 4
ggufMaxLayerArray = 1 << 16
)
// Types de valeur GGUF (gguf.h).
const (
ggufUint8 uint32 = iota
ggufInt8
ggufUint16
ggufInt16
ggufUint32
ggufInt32
ggufFloat32
ggufBool
ggufString
ggufArray
ggufUint64
ggufInt64
ggufFloat64
)
var errGGUFTruncated = errors.New("GGUF tronqué")
// ggufScalarSize : taille en octets d'un type de taille fixe, 0 sinon.
func ggufScalarSize(t uint32) int64 {
switch t {
case ggufUint8, ggufInt8, ggufBool:
return 1
case ggufUint16, ggufInt16:
return 2
case ggufUint32, ggufInt32, ggufFloat32:
return 4
case ggufUint64, ggufInt64, ggufFloat64:
return 8
}
return 0
}
// ggufReader lit séquentiellement un GGUF en comptant ce qui reste du fichier :
// toute longueur annoncée au-delà est une incohérence, détectée AVANT de lire.
type ggufReader struct {
r *bufio.Reader
bo binary.ByteOrder
v1 bool // GGUF v1 : compteurs et longueurs sur 32 bits
size int64 // taille totale du fichier
left int64 // octets restants dans le fichier
buf [8]byte
}
func (g *ggufReader) fixed(n int) ([]byte, error) {
if int64(n) > g.left {
return nil, errGGUFTruncated
}
b := g.buf[:n]
if _, err := io.ReadFull(g.r, b); err != nil {
return nil, errGGUFTruncated
}
g.left -= int64(n)
return b, nil
}
func (g *ggufReader) u32() (uint32, error) {
b, err := g.fixed(4)
if err != nil {
return 0, err
}
return g.bo.Uint32(b), nil
}
func (g *ggufReader) u64() (uint64, error) {
b, err := g.fixed(8)
if err != nil {
return 0, err
}
return g.bo.Uint64(b), nil
}
// count lit un compteur ou une longueur : 32 bits en v1, 64 bits ensuite.
func (g *ggufReader) count() (uint64, error) {
if g.v1 {
v, err := g.u32()
return uint64(v), err
}
return g.u64()
}
// countSize : taille sur disque d'un compteur, pour borner les tableaux de chaînes.
func (g *ggufReader) countSize() int64 {
if g.v1 {
return 4
}
return 8
}
func (g *ggufReader) skip(n int64) error {
if n < 0 || n > g.left {
return errGGUFTruncated
}
for rest := n; rest > 0; {
step := min(rest, 1<<30)
if _, err := g.r.Discard(int(step)); err != nil {
return errGGUFTruncated
}
rest -= step
}
g.left -= n
return nil
}
// str lit une chaîne d'au plus max octets.
func (g *ggufReader) str(max uint64) (string, error) {
n, err := g.count()
if err != nil {
return "", err
}
if n > max {
return "", fmt.Errorf("chaîne GGUF de %d octets", n)
}
if int64(n) > g.left {
return "", errGGUFTruncated
}
b := make([]byte, n)
if _, err := io.ReadFull(g.r, b); err != nil {
return "", errGGUFTruncated
}
g.left -= int64(n)
return string(b), nil
}
func (g *ggufReader) skipStr() error {
n, err := g.count()
if err != nil {
return err
}
if n > ggufMaxString {
return fmt.Errorf("chaîne GGUF de %d octets", n)
}
return g.skip(int64(n))
}
// intVal lit une valeur entière (ou booléenne) de type t. ok vaut false pour les
// flottants, lus et ignorés : aucune des clés retenues n'en est un.
func (g *ggufReader) intVal(t uint32) (v int64, ok bool, err error) {
size := ggufScalarSize(t)
if size == 0 {
return 0, false, fmt.Errorf("type GGUF %d inattendu", t)
}
b, err := g.fixed(int(size))
if err != nil {
return 0, false, err
}
switch t {
case ggufUint8, ggufBool:
return int64(b[0]), true, nil
case ggufInt8:
return int64(int8(b[0])), true, nil
case ggufUint16:
return int64(g.bo.Uint16(b)), true, nil
case ggufInt16:
return int64(int16(g.bo.Uint16(b))), true, nil
case ggufUint32:
return int64(g.bo.Uint32(b)), true, nil
case ggufInt32:
return int64(int32(g.bo.Uint32(b))), true, nil
case ggufUint64:
u := g.bo.Uint64(b)
if u > math.MaxInt64 {
u = math.MaxInt64
}
return int64(u), true, nil
case ggufInt64:
return int64(g.bo.Uint64(b)), true, nil
}
return 0, false, nil // flottant
}
// skipArray saute n éléments de type et, tableaux imbriqués compris (profondeur
// bornée). Le vocabulaire (150 000 chaînes) passe par ici sans rien allouer.
func (g *ggufReader) skipArray(et uint32, n uint64, depth int) error {
if depth > ggufMaxArrayDepth {
return errors.New("tableaux GGUF trop imbriqués")
}
if size := ggufScalarSize(et); size > 0 {
if n > uint64(g.left/size) {
return errGGUFTruncated
}
return g.skip(int64(n) * size)
}
switch et {
case ggufString:
if n > uint64(g.left/g.countSize()) {
return errGGUFTruncated
}
for i := uint64(0); i < n; i++ {
if err := g.skipStr(); err != nil {
return err
}
}
return nil
case ggufArray:
// Chaque sous-tableau coûte au moins son type et son compteur.
if n > uint64(g.left/(4+g.countSize())) {
return errGGUFTruncated
}
for i := uint64(0); i < n; i++ {
sub, err := g.u32()
if err != nil {
return err
}
sn, err := g.count()
if err != nil {
return err
}
if err := g.skipArray(sub, sn, depth+1); err != nil {
return err
}
}
return nil
}
return fmt.Errorf("type GGUF %d inconnu", et)
}
// ggufFile : ce qu'on retient d'UNE tranche avant de résoudre les clés selon
// l'architecture (general.architecture n'est pas forcément la première clé).
type ggufFile struct {
version int
arch string
ints map[string]int64
arrays map[string][]int64 // tableaux d'entiers retenus (têtes KV par couche)
keys []string // noms de toutes les clés, pour repérer <arch>.ssm.*
tensors int
nextn []string // noms des tenseurs *.nextn.eh_proj.weight
}
// keepIntArray : les seuls tableaux qu'on lit au lieu de les sauter.
func keepIntArray(key string) bool {
return strings.HasSuffix(key, ".attention.head_count_kv")
}
// readGGUFHeader lit l'en-tête, les clés et la table des tenseurs d'un fichier.
func readGGUFHeader(path string) (*ggufFile, error) {
fh, err := os.Open(path)
if err != nil {
return nil, err
}
defer fh.Close()
st, err := fh.Stat()
if err != nil {
return nil, err
}
if st.IsDir() {
return nil, fmt.Errorf("%s est un dossier", baseName(path))
}
f, err := parseGGUF(fh, st.Size())
if err != nil {
return nil, fmt.Errorf("%s : %w", baseName(path), err)
}
return f, nil
}
// parseGGUF lit size octets au plus de r. Un panic éventuel (fichier
// pathologique non prévu par les bornes) est rattrapé en erreur : lire des
// métadonnées ne doit jamais faire tomber Loki.
func parseGGUF(r io.Reader, size int64) (f *ggufFile, err error) {
defer func() {
if p := recover(); p != nil {
f, err = nil, fmt.Errorf("GGUF illisible : %v", p)
}
}()
g := &ggufReader{r: bufio.NewReaderSize(r, 1<<20), bo: binary.LittleEndian, size: size, left: size}
magic, err := g.fixed(4)
if err != nil {
return nil, err
}
if string(magic) != "GGUF" {
return nil, errors.New("pas un fichier GGUF")
}
ver, err := g.u32()
if err != nil {
return nil, err
}
// Les GGUF gros-boutistes (s390x) gardent la même signature : seule la
// version lue à l'envers trahit l'ordre des octets.
if ver&0xFFFF == 0 {
if sw := bits.ReverseBytes32(ver); sw >= 1 && sw <= 3 {
g.bo, ver = binary.BigEndian, sw
}
}
if ver < 1 || ver > 3 {
return nil, fmt.Errorf("version GGUF %d non prise en charge", ver)
}
g.v1 = ver == 1
nTensors, err := g.count()
if err != nil {
return nil, err
}
nKV, err := g.count()
if err != nil {
return nil, err
}
if nKV > ggufMaxKV || nTensors > ggufMaxTensors {
return nil, fmt.Errorf("compteurs GGUF aberrants (%d clés, %d tenseurs)", nKV, nTensors)
}
f = &ggufFile{version: int(ver), ints: map[string]int64{}, arrays: map[string][]int64{}, tensors: int(nTensors)}
for i := uint64(0); i < nKV; i++ {
key, err := g.str(ggufMaxKeyLen)
if err != nil {
return nil, err
}
t, err := g.u32()
if err != nil {
return nil, err
}
f.keys = append(f.keys, key)
switch t {
case ggufString:
if key == "general.architecture" {
if f.arch, err = g.str(ggufMaxKeyLen); err != nil {
return nil, err
}
} else if err := g.skipStr(); err != nil {
return nil, err
}
case ggufArray:
et, err := g.u32()
if err != nil {
return nil, err
}
n, err := g.count()
if err != nil {
return nil, err
}
if keepIntArray(key) && et != ggufFloat32 && et != ggufFloat64 && ggufScalarSize(et) > 0 && n <= ggufMaxLayerArray {
vals := make([]int64, 0, min(n, uint64(g.left)))
for j := uint64(0); j < n; j++ {
v, _, err := g.intVal(et)
if err != nil {
return nil, err
}
vals = append(vals, v)
}
f.arrays[key] = vals
} else if err := g.skipArray(et, n, 1); err != nil {
return nil, err
}
default:
v, ok, err := g.intVal(t)
if err != nil {
return nil, err
}
if ok {
f.ints[key] = v
}
}
}
dimSize := int64(8)
if g.v1 {
dimSize = 4
}
var maxOff uint64
for i := uint64(0); i < nTensors; i++ {
name, err := g.str(ggufMaxTensorName)
if err != nil {
return nil, err
}
nd, err := g.u32()
if err != nil {
return nil, err
}
if nd > ggufMaxDims {
return nil, fmt.Errorf("tenseur %q à %d dimensions", name, nd)
}
// dimensions et type (u32), puis position des données (u64)
if err := g.skip(int64(nd)*dimSize + 4); err != nil {
return nil, err
}
off, err := g.u64()
if err != nil {
return nil, err
}
maxOff = max(maxOff, off)
if strings.HasSuffix(name, ".nextn.eh_proj.weight") {
f.nextn = append(f.nextn, name)
}
}
// Les données commencent au prochain multiple de general.alignment. Le début
// du dernier tenseur doit tenir dans le fichier : un GGUF coupé dans ses poids
// (copie interrompue, disque plein) a un en-tête intact, il faut le voir
// quand même. On ne connaît pas la taille de chaque tenseur sans la table des
// types ggml : vérifier son premier octet suffit à attraper l'essentiel.
align := int64(32)
if a, ok := f.ints["general.alignment"]; ok {
if a <= 0 || a > 1<<20 || a&(a-1) != 0 {
return nil, fmt.Errorf("alignement GGUF %d invalide", a)
}
align = a
}
if nTensors > 0 {
pos := g.size - g.left
dataStart := (pos + align - 1) / align * align
if maxOff >= uint64(g.size) || dataStart+int64(maxOff) >= g.size {
return nil, errGGUFTruncated
}
}
return f, nil
}
// clampInt ramène une valeur de métadonnée dans [0, MaxInt32] : un champ négatif
// ou démesuré n'a pas de sens pour un compte de couches ou de têtes.
func clampInt(v int64) int {
if v < 0 {
return 0
}
if v > math.MaxInt32 {
return math.MaxInt32
}
return int(v)
}
// ggufInfoFrom résout les clés de la première tranche selon l'architecture et
// cherche la tête MTP dans les tenseurs de toutes les tranches.
func ggufInfoFrom(first *ggufFile, all []*ggufFile) GGUFInfo {
in := GGUFInfo{Version: first.version, Arch: first.arch}
p := first.arch + "."
get := func(k string) int { return clampInt(first.ints[p+k]) }
in.BlockCount = get("block_count")
in.NextN = get("nextn_predict_layers")
in.ContextLength = get("context_length")
in.ExpertCount = get("expert_count")
in.KeyLen = get("attention.key_length")
in.ValLen = get("attention.value_length")
in.FullAttnInterval = get("full_attention_interval")
in.EmbeddingLength = get("embedding_length")
in.HeadCount = get("attention.head_count")
in.SSMConv = get("ssm.conv_kernel")
in.SSMInner = get("ssm.inner_size")
in.SSMState = get("ssm.state_size")
in.SSMGroups = get("ssm.group_count")
in.HeadCountKV = get("attention.head_count_kv")
if arr, ok := first.arrays[p+"attention.head_count_kv"]; ok {
in.HeadCountKVLayers = make([]int, len(arr))
in.HeadCountKV = 0
for i, v := range arr {
in.HeadCountKVLayers[i] = clampInt(v)
in.HeadCountKV = max(in.HeadCountKV, in.HeadCountKVLayers[i])
}
}
if first.arch != "" {
for _, k := range first.keys {
if strings.HasPrefix(k, p+"ssm.") {
in.Hybrid = true
break
}
}
}
want := ""
if in.BlockCount > 0 {
want = "blk." + strconv.Itoa(in.BlockCount-1) + ".nextn.eh_proj.weight"
}
for _, f := range all {
in.TensorCount += f.tensors
for _, n := range f.nextn {
if n == want {
in.HasNextNTensor = true
}
}
}
return in
}
// Cache par chemin : la même liste de modèles est consultée à chaque démarrage
// et à chaque affichage. L'empreinte (taille et date de chaque tranche) suffit à
// voir un fichier remplacé ou un téléchargement terminé ; les erreurs ne sont
// pas gardées, un fichier en cours de téléchargement sera relu une fois complet.
type ggufCacheEntry struct {
stamp string
info GGUFInfo
}
var (
ggufCacheMu sync.Mutex
ggufCache = map[string]ggufCacheEntry{}
)
const ggufCacheMax = 256
// ggufMeta lit les métadonnées du modèle désigné par path. Pour un modèle en
// tranches, path peut désigner n'importe laquelle : les clés viennent de la
// première (les suivantes ne portent que split.*), les tenseurs de toutes. Une
// tranche manquante est une erreur : le modèle ne démarrerait pas, et on ne
// saurait pas dire si la tête MTP s'y trouvait.
func ggufMeta(path string) (GGUFInfo, error) {
path = filepath.Clean(path)
dir := filepath.Dir(path)
fam := shardFamily(path)
paths := make([]string, len(fam))
var stamp strings.Builder
for i, n := range fam {
paths[i] = filepath.Join(dir, n)
st, err := os.Stat(paths[i])
if err != nil {
return GGUFInfo{}, err
}
if st.IsDir() {
return GGUFInfo{}, fmt.Errorf("%s est un dossier", n)
}
fmt.Fprintf(&stamp, "%d:%d;", st.Size(), st.ModTime().UnixNano())
}
key := paths[0]
ggufCacheMu.Lock()
e, ok := ggufCache[key]
ggufCacheMu.Unlock()
if ok && e.stamp == stamp.String() {
return cloneGGUFInfo(e.info), nil
}
files := make([]*ggufFile, 0, len(paths))
for _, p := range paths {
f, err := readGGUFHeader(p)
if err != nil {
return GGUFInfo{}, err
}
files = append(files, f)
}
info := ggufInfoFrom(files[0], files)
ggufCacheMu.Lock()
if len(ggufCache) >= ggufCacheMax {
ggufCache = map[string]ggufCacheEntry{}
}
ggufCache[key] = ggufCacheEntry{stamp: stamp.String(), info: info}
ggufCacheMu.Unlock()
return cloneGGUFInfo(info), nil
}
// cloneGGUFInfo : l'appelant peut modifier le tableau par couche sans toucher au
// cache.
func cloneGGUFInfo(in GGUFInfo) GGUFInfo {
if in.HeadCountKVLayers != nil {
in.HeadCountKVLayers = append([]int(nil), in.HeadCountKVLayers...)
}
return in
}
+417
View File
@@ -0,0 +1,417 @@
package loki
import (
"bytes"
"encoding/binary"
"math"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
// ggufBuilder écrit un GGUF synthétique : en-tête, clés typées, table des
// tenseurs, sans aucune donnée de poids (le lecteur n'en lit jamais).
type ggufBuilder struct {
bo binary.ByteOrder
version uint32
nKV int
nT int
kv bytes.Buffer
tensors bytes.Buffer
}
func newGGUF() *ggufBuilder { return &ggufBuilder{bo: binary.LittleEndian, version: 3} }
func (b *ggufBuilder) cnt(w *bytes.Buffer, n uint64) {
if b.version == 1 {
binary.Write(w, b.bo, uint32(n))
} else {
binary.Write(w, b.bo, n)
}
}
func (b *ggufBuilder) str(w *bytes.Buffer, s string) {
b.cnt(w, uint64(len(s)))
w.WriteString(s)
}
func (b *ggufBuilder) key(k string, t uint32) {
b.nKV++
b.str(&b.kv, k)
binary.Write(&b.kv, b.bo, t)
}
func (b *ggufBuilder) u32(k string, v uint32) *ggufBuilder {
b.key(k, ggufUint32)
binary.Write(&b.kv, b.bo, v)
return b
}
func (b *ggufBuilder) i64(k string, v int64) *ggufBuilder {
b.key(k, ggufInt64)
binary.Write(&b.kv, b.bo, v)
return b
}
func (b *ggufBuilder) f32(k string, v float32) *ggufBuilder {
b.key(k, ggufFloat32)
binary.Write(&b.kv, b.bo, math.Float32bits(v))
return b
}
func (b *ggufBuilder) boolean(k string, v bool) *ggufBuilder {
b.key(k, ggufBool)
if v {
b.kv.WriteByte(1)
} else {
b.kv.WriteByte(0)
}
return b
}
func (b *ggufBuilder) s(k, v string) *ggufBuilder {
b.key(k, ggufString)
b.str(&b.kv, v)
return b
}
func (b *ggufBuilder) strArr(k string, vs []string) *ggufBuilder {
b.key(k, ggufArray)
binary.Write(&b.kv, b.bo, ggufString)
b.cnt(&b.kv, uint64(len(vs)))
for _, v := range vs {
b.str(&b.kv, v)
}
return b
}
func (b *ggufBuilder) i32Arr(k string, vs []int32) *ggufBuilder {
b.key(k, ggufArray)
binary.Write(&b.kv, b.bo, ggufInt32)
b.cnt(&b.kv, uint64(len(vs)))
for _, v := range vs {
binary.Write(&b.kv, b.bo, v)
}
return b
}
// nested : tableau de deux tableaux de u8, pour vérifier le saut récursif.
func (b *ggufBuilder) nested(k string) *ggufBuilder {
b.key(k, ggufArray)
binary.Write(&b.kv, b.bo, ggufArray)
b.cnt(&b.kv, 2)
for i := 0; i < 2; i++ {
binary.Write(&b.kv, b.bo, ggufUint8)
b.cnt(&b.kv, 3)
b.kv.Write([]byte{1, 2, 3})
}
return b
}
func (b *ggufBuilder) tensor(name string, dims ...uint64) *ggufBuilder {
b.str(&b.tensors, name)
binary.Write(&b.tensors, b.bo, uint32(len(dims)))
for _, d := range dims {
if b.version == 1 {
binary.Write(&b.tensors, b.bo, uint32(d))
} else {
binary.Write(&b.tensors, b.bo, d)
}
}
binary.Write(&b.tensors, b.bo, uint32(0)) // type F32
binary.Write(&b.tensors, b.bo, uint64(b.nT*32)) // position des données
b.nT++
return b
}
func (b *ggufBuilder) bytes() []byte {
var out bytes.Buffer
out.WriteString("GGUF")
binary.Write(&out, b.bo, b.version)
b.cnt(&out, uint64(b.nT))
b.cnt(&out, uint64(b.nKV))
out.Write(b.kv.Bytes())
out.Write(b.tensors.Bytes())
// bourrage jusqu'à l'alignement par défaut, puis 32 octets de « poids » par
// tenseur
if b.nT > 0 {
out.Write(make([]byte, (32-out.Len()%32)%32+32*b.nT))
}
return out.Bytes()
}
func writeGGUF(t *testing.T, dir, name string, data []byte) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, data, 0o644); err != nil {
t.Fatal(err)
}
return p
}
// qwenMTP : modèle hybride à 4 couches, têtes KV par couche (0 sur les couches
// récurrentes), tête MTP sur la dernière couche, vocabulaire à sauter.
func qwenMTP(b *ggufBuilder) *ggufBuilder {
return b.u32("general.alignment", 32).
strArr("tokenizer.ggml.tokens", []string{"<s>", "a", "bb", strings.Repeat("c", 300)}).
s("general.architecture", "qwen35").
s("general.name", "Qwen synthétique").
u32("qwen35.block_count", 4).
u32("qwen35.nextn_predict_layers", 1).
i64("qwen35.context_length", 262144).
u32("qwen35.attention.key_length", 256).
u32("qwen35.attention.value_length", 256).
i32Arr("qwen35.attention.head_count_kv", []int32{0, 0, 0, 4}).
u32("qwen35.full_attention_interval", 4).
u32("qwen35.ssm.conv_kernel", 4).
f32("qwen35.rope.freq_base", 1e7).
boolean("tokenizer.ggml.add_bos_token", false).
nested("bidon.imbrique").
tensor("token_embd.weight", 1024, 32000).
tensor("blk.0.attn_norm.weight", 1024).
tensor("blk.3.nextn.eh_proj.weight", 2048, 1024)
}
func TestGGUFMetaQwenHybrideMTP(t *testing.T) {
p := writeGGUF(t, t.TempDir(), "qwen.gguf", qwenMTP(newGGUF()).bytes())
got, err := ggufMeta(p)
if err != nil {
t.Fatal(err)
}
want := GGUFInfo{
Version: 3, Arch: "qwen35", BlockCount: 4, NextN: 1, ContextLength: 262144,
KeyLen: 256, ValLen: 256, HeadCountKV: 4, HeadCountKVLayers: []int{0, 0, 0, 4},
FullAttnInterval: 4, Hybrid: true, SSMConv: 4, HasNextNTensor: true, TensorCount: 3,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ggufMeta =\n%+v\nattendu\n%+v", got, want)
}
}
// La clé nextn_predict_layers ne prouve rien : seul le tenseur de la DERNIÈRE
// couche compte, comme dans llama.cpp.
func TestGGUFMetaTeteMTPAbsenteOuMalPlacee(t *testing.T) {
dir := t.TempDir()
base := func() *ggufBuilder {
return newGGUF().s("general.architecture", "glm4moe").
u32("glm4moe.block_count", 47).
u32("glm4moe.nextn_predict_layers", 1).
u32("glm4moe.expert_count", 128).
u32("glm4moe.attention.head_count_kv", 8)
}
cases := []struct {
name string
b *ggufBuilder
}{
{"sans-tenseur.gguf", base().tensor("blk.45.ffn_up.weight", 8, 8)},
{"mauvaise-couche.gguf", base().tensor("blk.45.nextn.eh_proj.weight", 8, 8)},
}
for _, c := range cases {
got, err := ggufMeta(writeGGUF(t, dir, c.name, c.b.bytes()))
if err != nil {
t.Fatalf("%s : %v", c.name, err)
}
if got.HasNextNTensor || got.NextN != 1 || got.ExpertCount != 128 || got.HeadCountKV != 8 ||
got.HeadCountKVLayers != nil || got.Hybrid {
t.Fatalf("%s : %+v", c.name, got)
}
}
got, err := ggufMeta(writeGGUF(t, dir, "ok.gguf", base().tensor("blk.46.nextn.eh_proj.weight", 8, 8).bytes()))
if err != nil || !got.HasNextNTensor {
t.Fatalf("tête MTP en dernière couche non vue : %+v, %v", got, err)
}
}
// Modèle en tranches : les clés sont dans la première, la tête MTP dans la
// dernière. N'importe quelle tranche peut être désignée.
func TestGGUFMetaTranches(t *testing.T) {
dir := t.TempDir()
first := newGGUF().s("general.architecture", "qwen35").u32("qwen35.block_count", 4).
u32("split.count", 2).tensor("token_embd.weight", 8, 8)
second := newGGUF().u32("split.no", 1).u32("split.count", 2).
tensor("blk.3.nextn.eh_proj.weight", 8, 8).tensor("output.weight", 8, 8)
p1 := writeGGUF(t, dir, "m-00001-of-00002.gguf", first.bytes())
writeGGUF(t, dir, "m-00002-of-00002.gguf", second.bytes())
for _, p := range []string{p1, filepath.Join(dir, "m-00002-of-00002.gguf")} {
got, err := ggufMeta(p)
if err != nil {
t.Fatal(err)
}
if got.Arch != "qwen35" || !got.HasNextNTensor || got.TensorCount != 3 {
t.Fatalf("%s : %+v", baseName(p), got)
}
}
// tranche manquante : on ne sait pas, donc erreur
writeGGUF(t, dir, "n-00001-of-00002.gguf", first.bytes())
if _, err := ggufMeta(filepath.Join(dir, "n-00001-of-00002.gguf")); err == nil {
t.Fatal("tranche manquante acceptée")
}
// gguf-split --no-tensor-first-split : la première tranche ne porte que les
// clés, aucun tenseur ni aucune donnée. Elle doit passer telle quelle.
meta := newGGUF().s("general.architecture", "glm4moe").u32("glm4moe.block_count", 47).
u32("split.count", 2)
rest := newGGUF().u32("split.no", 1).tensor("blk.46.nextn.eh_proj.weight", 8, 8)
writeGGUF(t, dir, "s-00001-of-00002.gguf", meta.bytes())
writeGGUF(t, dir, "s-00002-of-00002.gguf", rest.bytes())
got, err := ggufMeta(filepath.Join(dir, "s-00001-of-00002.gguf"))
if err != nil || got.Arch != "glm4moe" || !got.HasNextNTensor || got.TensorCount != 1 {
t.Fatalf("première tranche sans tenseur : %+v, %v", got, err)
}
}
// v1 (compteurs 32 bits) et gros-boutiste : même résultat que le v3 ordinaire.
func TestGGUFMetaVersionsEtOrdre(t *testing.T) {
dir := t.TempDir()
v1 := newGGUF()
v1.version = 1
v2 := newGGUF()
v2.version = 2
be := newGGUF()
be.bo = binary.BigEndian
for name, b := range map[string]*ggufBuilder{"v1.gguf": v1, "v2.gguf": v2, "be.gguf": be} {
got, err := ggufMeta(writeGGUF(t, dir, name, qwenMTP(b).bytes()))
if err != nil {
t.Fatalf("%s : %v", name, err)
}
if got.Arch != "qwen35" || got.BlockCount != 4 || !got.HasNextNTensor ||
!reflect.DeepEqual(got.HeadCountKVLayers, []int{0, 0, 0, 4}) || got.Version != int(b.version) {
t.Fatalf("%s : %+v", name, got)
}
}
}
// Fichier en cours de téléchargement : chaque coupure donne une erreur, jamais
// un panic ni un faux résultat.
func TestGGUFMetaTronque(t *testing.T) {
dir := t.TempDir()
full := qwenMTP(newGGUF()).bytes()
// Toutes les coupures jusqu'au premier octet du dernier tenseur (le seul que
// le lecteur sait situer sans la table des types ggml).
for cut := 0; cut <= len(full)-32; cut++ {
if _, err := parseGGUF(bytes.NewReader(full[:cut]), int64(cut)); err == nil {
t.Fatalf("coupure à %d/%d acceptée", cut, len(full))
}
}
if _, err := parseGGUF(bytes.NewReader(full), int64(len(full))); err != nil {
t.Fatalf("fichier complet refusé : %v", err)
}
// et par le chemin fichier, pour une coupure dans l'en-tête
if _, err := ggufMeta(writeGGUF(t, dir, "cut.gguf", full[:len(full)/3])); err == nil {
t.Fatal("fichier coupé accepté")
}
}
func TestGGUFMetaFichiersAberrants(t *testing.T) {
dir := t.TempDir()
hdr := func(ver uint32, nT, nKV uint64) *bytes.Buffer {
var w bytes.Buffer
w.WriteString("GGUF")
binary.Write(&w, binary.LittleEndian, ver)
binary.Write(&w, binary.LittleEndian, nT)
binary.Write(&w, binary.LittleEndian, nKV)
return &w
}
// clé annoncée à 1 Eo
hugeKey := hdr(3, 0, 1)
binary.Write(hugeKey, binary.LittleEndian, uint64(1)<<60)
// tableau de chaînes annoncé à 2^62 éléments
hugeArr := hdr(3, 0, 1)
binary.Write(hugeArr, binary.LittleEndian, uint64(1))
hugeArr.WriteString("k")
binary.Write(hugeArr, binary.LittleEndian, ggufArray)
binary.Write(hugeArr, binary.LittleEndian, ggufString)
binary.Write(hugeArr, binary.LittleEndian, uint64(1)<<62)
// type de valeur inconnu
badType := hdr(3, 0, 1)
binary.Write(badType, binary.LittleEndian, uint64(1))
badType.WriteString("k")
binary.Write(badType, binary.LittleEndian, uint32(99))
binary.Write(badType, binary.LittleEndian, uint64(0))
// tenseur à 1000 dimensions
badDims := hdr(3, 1, 0)
binary.Write(badDims, binary.LittleEndian, uint64(1))
badDims.WriteString("t")
binary.Write(badDims, binary.LittleEndian, uint32(1000))
badDims.Write(make([]byte, 64))
cases := map[string][]byte{
"vide": nil,
"alignement": newGGUF().u32("general.alignment", 24).tensor("t", 1).bytes(),
"signature": []byte("GGML\x03\x00\x00\x00" + strings.Repeat("\x00", 16)),
"version": hdr(4, 0, 0).Bytes(),
"compteurs": hdr(3, 1<<40, 1<<40).Bytes(),
"cle-geante": hugeKey.Bytes(),
"tableau": hugeArr.Bytes(),
"type-inconnu": badType.Bytes(),
"dimensions": badDims.Bytes(),
}
for name, data := range cases {
if _, err := ggufMeta(writeGGUF(t, dir, name+".gguf", data)); err == nil {
t.Errorf("%s : accepté", name)
}
}
if _, err := ggufMeta(filepath.Join(dir, "absent.gguf")); err == nil {
t.Error("fichier absent accepté")
}
}
// Octet corrompu n'importe où dans l'en-tête (v1 et v3) : le lecteur doit rendre
// la main vite, sans panic remonté ni allocation démesurée. Les valeurs 0xFF et
// 0x7F transforment un compteur en nombre géant, le cas qui ferait mal.
func TestGGUFMetaOctetCorrompu(t *testing.T) {
v1 := newGGUF()
v1.version = 1
for _, b := range []*ggufBuilder{newGGUF(), v1} {
full := qwenMTP(b).bytes()
hdr := len(full) - 3*32 // le bourrage et les « poids » ne sont jamais lus
for i := 0; i < hdr; i++ {
for _, v := range []byte{0xFF, 0x7F, 0x00} {
mut := append([]byte(nil), full...)
mut[i] = v
// le filet de sécurité existe, mais aucune entrée ne doit y tomber
if _, err := parseGGUF(bytes.NewReader(mut), int64(len(mut))); err != nil &&
strings.Contains(err.Error(), "illisible") {
t.Fatalf("v%d, octet %d = %#x : %v", b.version, i, v, err)
}
}
}
}
}
// Le cache tient tant que taille et date sont les mêmes, et se renouvelle dès
// qu'elles changent (téléchargement terminé, fichier remplacé).
func TestGGUFMetaCache(t *testing.T) {
dir := t.TempDir()
data := qwenMTP(newGGUF()).bytes()
p := writeGGUF(t, dir, "c.gguf", data)
stamp := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
if err := os.Chtimes(p, stamp, stamp); err != nil {
t.Fatal(err)
}
a, err := ggufMeta(p)
if err != nil {
t.Fatal(err)
}
a.HeadCountKVLayers[0] = 99 // ne doit pas atteindre le cache
// même taille, même date, contenu illisible : le cache répond
writeGGUF(t, dir, "c.gguf", make([]byte, len(data)))
if err := os.Chtimes(p, stamp, stamp); err != nil {
t.Fatal(err)
}
b, err := ggufMeta(p)
if err != nil || b.HeadCountKVLayers[0] != 0 || !b.HasNextNTensor {
t.Fatalf("cache non utilisé ou altéré : %+v, %v", b, err)
}
// date différente : relecture, donc erreur sur le contenu illisible
if err := os.Chtimes(p, stamp.Add(time.Second), stamp.Add(time.Second)); err != nil {
t.Fatal(err)
}
if _, err := ggufMeta(p); err == nil {
t.Fatal("fichier modifié servi depuis le cache")
}
}
+124 -10
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)
}
}
+183 -3
View File
@@ -67,6 +67,11 @@ type hfRepo struct {
ID string `json:"id"`
Downloads int `json:"downloads"`
Likes int `json:"likes"`
// Gated : le dépôt exige d'avoir accepté ses conditions ET un jeton. Il se
// LIT pourtant sans rien (arborescence en 200), et ne refuse qu'au moment du
// transfert : sans cette pastille, l'interface propose des quants avec leur
// verdict mémoire et le téléchargement échoue en 401 sans prévenir.
Gated bool `json:"gated"`
}
// hfEntry — un .gguf installable. Shards > 1 signale une famille de tranches :
@@ -85,6 +90,7 @@ type hfEntry struct {
// hfListing — le contenu utile d'un dépôt, trié.
type hfListing struct {
Repo string `json:"repo"`
Gated bool `json:"gated"`
Models []hfEntry `json:"models"`
Projectors []hfEntry `json:"projectors"`
Drafts []hfEntry `json:"drafts"`
@@ -95,13 +101,115 @@ type hfListing struct {
// transfert : sans lui un dépôt gated répond 401 et Loki afficherait « aucun
// résultat » là où il faudrait dire « dépôt à accès restreint ».
func hfAuth(req *http.Request) {
if k := os.Getenv("HF_TOKEN"); k != "" {
if k := hfToken(); k != "" {
req.Header.Set("Authorization", "Bearer "+k)
}
req.Header.Set("User-Agent", "loki/"+Version)
req.Header.Set("Accept", "application/json")
}
// ---- Jeton Hugging Face -----------------------------------------------------
//
// Le jeton vivait UNIQUEMENT dans la variable d'environnement HF_TOKEN. En
// conteneur, c'était l'exiger au mauvais moment : on découvre qu'un dépôt est
// verrouillé depuis l'interface, et il faudrait éditer un docker-compose puis
// recréer le conteneur pour y répondre. Il se règle donc aussi dans l'interface,
// et se range là où sont déjà les secrets (base d'état) — pas dans config.env,
// que le changement de preset réécrit en bloc.
//
// Priorité : le jeton enregistré d'abord, la variable d'environnement ensuite.
// Rien d'enregistré = comportement d'avant, à l'octet près.
// hfToken renvoie le jeton effectivement utilisé, ou "".
func hfToken() string {
if t := strings.TrimSpace(getStr(bkState, "hf_token")); t != "" {
return t
}
return strings.TrimSpace(os.Getenv("HF_TOKEN"))
}
// hfTokenSource dit D'OÙ vient le jeton : « config » (enregistré ici), « env »
// (HF_TOKEN du conteneur) ou "" (aucun). L'interface en a besoin pour ne pas
// proposer d'effacer un jeton qu'elle ne peut pas effacer.
func hfTokenSource() string {
switch {
case strings.TrimSpace(getStr(bkState, "hf_token")) != "":
return "config"
case strings.TrimSpace(os.Getenv("HF_TOKEN")) != "":
return "env"
}
return ""
}
// hfTokenSet dit si un jeton est disponible, pour que l'interface distingue
// « il faut en poser un » de « celui qui est posé ne suffit pas ».
func hfTokenSet() bool { return hfToken() != "" }
// writeHFToken enregistre (ou efface, tok == "") le jeton. Le cache des réponses
// Hugging Face est vidé au passage : il contient des listes obtenues SANS jeton,
// où un dépôt verrouillé peut manquer ou paraître illisible.
func writeHFToken(tok string) error {
if err := putStr(bkState, "hf_token", strings.TrimSpace(tok)); err != nil {
return err
}
hfCacheMu.Lock()
hfCache = map[string]hfCacheItem{}
hfCacheMu.Unlock()
return nil
}
// maskHFToken n'affiche qu'assez de caractères pour reconnaître le jeton posé.
// Il n'est jamais renvoyé en clair : contrairement à la clé API du serveur,
// personne n'a besoin de le recopier depuis Loki.
func maskHFToken(t string) string {
t = strings.TrimSpace(t)
if t == "" {
return ""
}
if len(t) <= 8 {
return "…" + t[len(t)-2:]
}
return t[:3] + "…" + t[len(t)-4:]
}
// hfWhoAmI vérifie un jeton auprès de Hugging Face et renvoie le nom du compte.
// Enregistrer un jeton sans le vérifier, c'est déplacer l'échec : on croirait le
// problème réglé jusqu'au prochain 401, quinze minutes de téléchargement plus
// tard.
func hfWhoAmI(ctx context.Context, tok string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, hfTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", hfHost+"/api/whoami-v2", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(tok))
req.Header.Set("User-Agent", "loki/"+Version)
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("Hugging Face injoignable : %v", err)
}
defer resp.Body.Close()
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return "", fmt.Errorf("jeton refusé par Hugging Face (expiré, révoqué ou mal copié)")
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("Hugging Face a répondu %d", resp.StatusCode)
}
var who struct {
Name string `json:"name"`
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
if err := json.Unmarshal(raw, &who); err != nil {
return "", err
}
return who.Name, nil
}
// hfCache : mémo commun à la recherche et aux arborescences, clé = l'URL
// appelée. Sans lui, chaque frappe au clavier dans le champ de recherche
// produit une requête sortante.
@@ -172,29 +280,99 @@ func hfSearch(ctx context.Context, q string) ([]hfRepo, error) {
if len(q) > hfMaxQuery {
q = q[:hfMaxQuery]
}
// `expand[]` remplace les champs par défaut de la réponse : `gated` s'y
// ajoute, mais downloads et likes doivent alors être redemandés
// explicitement, sinon ils disparaissent et la liste perd son classement
// lisible.
endpoint := hfHost + "/api/models?" + url.Values{
"search": {q},
"filter": {"gguf"},
"limit": {fmt.Sprint(hfMaxRepos)},
"sort": {"downloads"},
"direction": {"-1"},
"expand[]": {"gated", "downloads", "likes"},
}.Encode()
var raw []struct {
ID string `json:"id"`
Downloads int `json:"downloads"`
Likes int `json:"likes"`
Gated any `json:"gated"`
}
if err := hfGetJSON(ctx, endpoint, hfSearchTTL, &raw); err != nil {
return nil, err
}
out := make([]hfRepo, 0, len(raw))
for _, r := range raw {
out = append(out, hfRepo{ID: r.ID, Downloads: r.Downloads, Likes: r.Likes})
out = append(out, hfRepo{ID: r.ID, Downloads: r.Downloads, Likes: r.Likes, Gated: hfGatedFlag(r.Gated)})
}
return out, nil
}
// hfGatedFlag lit le champ `gated` de l'API, qui n'est PAS un booléen : il vaut
// false, "auto" (accepter les conditions suffit) ou "manual" (l'auteur valide
// chaque demande). Les deux dernières valeurs verrouillent le téléchargement de
// la même façon ; seule la présence d'un verrou nous intéresse ici.
func hfGatedFlag(v any) bool {
switch g := v.(type) {
case bool:
return g
case string:
switch strings.ToLower(strings.TrimSpace(g)) {
case "", "false", "none", "no":
return false
}
return true
}
return false
}
// hfRepoGated dit si un dépôt est verrouillé. L'arborescence (hfFiles) ne porte
// pas l'information : elle se lit sur la fiche du dépôt, en un appel séparé et
// mis en cache comme elle. Une erreur ici ne doit RIEN casser — au pire on
// n'affiche pas l'avertissement, et le téléchargement dira lui-même pourquoi il
// a été refusé.
func hfRepoGated(ctx context.Context, repo string) bool {
var info struct {
Gated any `json:"gated"`
}
endpoint := hfHost + "/api/models/" + repo + "?" + url.Values{"expand[]": {"gated"}}.Encode()
if err := hfGetJSON(ctx, endpoint, hfFilesTTL, &info); err != nil {
return false
}
return hfGatedFlag(info.Gated)
}
// isHFHost dit si un hôte appartient à Hugging Face. Sert à décider qui a le
// droit de recevoir le jeton.
func isHFHost(host string) bool {
h := strings.ToLower(host)
if i := strings.IndexByte(h, ':'); i >= 0 {
h = h[:i]
}
return h == "huggingface.co" || strings.HasSuffix(h, ".huggingface.co") ||
h == "hf.co" || strings.HasSuffix(h, ".hf.co")
}
// hfRepoFromURL retrouve « auteur/dépôt » dans un lien de téléchargement
// Hugging Face, pour pouvoir le nommer dans un message d'erreur. Renvoie "" si
// le lien pointe ailleurs.
func hfRepoFromURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || !strings.Contains(u.Host, "huggingface.co") {
return ""
}
segs := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(segs) < 4 || (segs[2] != "resolve" && segs[2] != "blob") {
return ""
}
repo := segs[0] + "/" + segs[1]
if !hfRepoRe.MatchString(repo) {
return ""
}
return repo
}
// hfFiles liste les .gguf d'un dépôt et les range par famille.
//
// `recursive=1` n'est pas un confort : les quants volumineux vivent dans des
@@ -229,7 +407,9 @@ func hfFiles(ctx context.Context, repo string) (hfListing, error) {
}
files = append(files, hfFile{Path: f.Path, Size: n})
}
return hfClassify(repo, files), nil
out := hfClassify(repo, files)
out.Gated = hfRepoGated(ctx, repo)
return out, nil
}
// hfFile — une entrée d'arborescence, réduite à ce dont le classement a besoin.
+152
View File
@@ -1,6 +1,10 @@
package loki
import (
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"strings"
"testing"
)
@@ -205,3 +209,151 @@ func TestFitVerdict(t *testing.T) {
t.Errorf("le contexte ne change rien au verdict (%q dans les deux cas)", court)
}
}
// Le champ `gated` de l'API n'est pas un booléen : false, "auto" ou "manual".
// Le lire comme un bool laissait passer les deux valeurs qui verrouillent
// vraiment le téléchargement.
func TestHFGatedFlag(t *testing.T) {
for _, c := range []struct {
in any
want bool
}{
{nil, false},
{false, false},
{true, true},
{"auto", true},
{"manual", true},
{"false", false},
{"", false},
} {
if got := hfGatedFlag(c.in); got != c.want {
t.Errorf("hfGatedFlag(%#v) = %v, attendu %v", c.in, got, c.want)
}
}
}
// hfRepoFromURL sert à NOMMER le dépôt dans le message d'erreur : un lien qui
// ne vient pas de Hugging Face ne doit rien produire plutôt qu'un nom inventé.
func TestHFRepoFromURL(t *testing.T) {
for _, c := range []struct{ in, want string }{
{"https://huggingface.co/orcarouter/Qwen3.8-27B-Uncensored-GGUF/resolve/main/m.gguf", "orcarouter/Qwen3.8-27B-Uncensored-GGUF"},
{"https://huggingface.co/ggml-org/Qwen3.8-27B-GGUF/blob/main/sub/dir/m.gguf", "ggml-org/Qwen3.8-27B-GGUF"},
{"https://example.com/ggml-org/Qwen3.8-27B-GGUF/resolve/main/m.gguf", ""},
{"https://huggingface.co/ggml-org/Qwen3.8-27B-GGUF", ""},
{"pas une url", ""},
} {
if got := hfRepoFromURL(c.in); got != c.want {
t.Errorf("hfRepoFromURL(%q) = %q, attendu %q", c.in, got, c.want)
}
}
}
// Le jeton enregistré dans l'interface prime sur HF_TOKEN, et son absence
// rend exactement le comportement d'avant : la variable d'environnement.
func TestHFTokenPrecedence(t *testing.T) {
testHome(t)
t.Setenv("HF_TOKEN", "")
if hfToken() != "" || hfTokenSource() != "" || hfTokenSet() {
t.Fatalf("sans rien : jeton=%q source=%q", hfToken(), hfTokenSource())
}
t.Setenv("HF_TOKEN", "hf_env")
if hfToken() != "hf_env" || hfTokenSource() != "env" {
t.Fatalf("HF_TOKEN seul : jeton=%q source=%q", hfToken(), hfTokenSource())
}
if err := writeHFToken(" hf_enregistre "); err != nil {
t.Fatal(err)
}
if hfToken() != "hf_enregistre" || hfTokenSource() != "config" {
t.Fatalf("jeton enregistré : jeton=%q source=%q", hfToken(), hfTokenSource())
}
// Retiré ici, la variable du conteneur reprend la main plutôt que de laisser
// Loki sans jeton alors que l'environnement en fournit un.
if err := writeHFToken(""); err != nil {
t.Fatal(err)
}
if hfToken() != "hf_env" || hfTokenSource() != "env" {
t.Fatalf("après retrait : jeton=%q source=%q", hfToken(), hfTokenSource())
}
}
// Le jeton ne doit jamais ressortir en clair de l'interface.
func TestMaskHFToken(t *testing.T) {
if got := maskHFToken("hf_abcdefghijklmnop"); got != "hf_…mnop" {
t.Errorf("masque = %q", got)
}
if got := maskHFToken(""); got != "" {
t.Errorf("masque d'un jeton absent = %q", got)
}
if strings.Contains(maskHFToken("hf_abcdefghijklmnop"), "efghij") {
t.Error("le masque laisse voir le milieu du jeton")
}
}
// Un secret ne part QUE vers Hugging Face : un lien collé vers un autre
// hébergeur ne doit pas recevoir le jeton du compte.
func TestDLRequestTokenOnlyToHuggingFace(t *testing.T) {
testHome(t)
t.Setenv("HF_TOKEN", "hf_secret")
for _, c := range []struct {
url string
want bool
}{
{"https://huggingface.co/a/b/resolve/main/m.gguf", true},
{"https://cdn-lfs.huggingface.co/a/b/m.gguf", true},
{"https://hf.co/a/b/resolve/main/m.gguf", true},
{"https://example.com/m.gguf", false},
{"https://huggingface.co.evil.example/m.gguf", false},
} {
req, err := dlRequest(context.Background(), c.url, "")
if err != nil {
t.Fatal(err)
}
if got := req.Header.Get("Authorization") != ""; got != c.want {
t.Errorf("%s : en-tête Authorization présent=%v, attendu %v", c.url, got, c.want)
}
}
}
// La route décrit le jeton sans jamais le rendre, et l'efface sans appeler
// Hugging Face (un jeton vide n'a rien à vérifier).
func TestHandleHFTokenGetAndClear(t *testing.T) {
testHome(t)
t.Setenv("HF_TOKEN", "")
if err := writeHFToken("hf_abcdefghijklmnop"); err != nil {
t.Fatal(err)
}
call := func(method, body string) map[string]any {
t.Helper()
r := httptest.NewRequest(method, "http://placeholder/api/hf/token", strings.NewReader(body))
w := httptest.NewRecorder()
handleHFToken(w, r)
if w.Code != 200 {
t.Fatalf("%s → HTTP %d : %s", method, w.Code, w.Body.String())
}
var out map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
got := call("GET", "")
if got["set"] != true || got["source"] != "config" {
t.Fatalf("état lu = %v", got)
}
if s, _ := got["masked"].(string); s == "" || strings.Contains(s, "efghij") {
t.Fatalf("masque inattendu : %q", s)
}
if strings.Contains(fmt.Sprint(got), "hf_abcdefghijklmnop") {
t.Fatalf("le jeton ressort en clair : %v", got)
}
got = call("POST", `{"token":""}`)
if got["set"] != false || got["source"] != "" {
t.Fatalf("après effacement = %v", got)
}
if hfToken() != "" {
t.Fatalf("jeton toujours présent : %q", hfToken())
}
}
+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
+4 -2
View File
@@ -264,10 +264,12 @@ func handleModelDirs(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true})
return
}
out := []map[string]any{{"path": modelsDir(), "home": true, "count": modelDirCount(modelsDir()), "exists": true, "free": diskFree(modelsDir())}}
out := []map[string]any{{"path": modelsDir(), "home": true, "count": modelDirCount(modelsDir()), "exists": true,
"free": diskFree(modelsDir()), "free_exact": diskFreeReliable(modelsDir())}}
for _, d := range extraModelDirs() {
n := modelDirCount(d)
out = append(out, map[string]any{"path": d, "home": false, "count": n, "exists": n >= 0, "free": diskFree(d)})
out = append(out, map[string]any{"path": d, "home": false, "count": n, "exists": n >= 0,
"free": diskFree(d), "free_exact": diskFreeReliable(d)})
}
sendJSON(w, 200, map[string]any{"ok": true, "dirs": out, "env": os.Getenv("LOKI_MODEL_DIRS") != ""})
}
+244 -15
View File
@@ -70,6 +70,79 @@ func reasoningActive(v string) bool {
return true
}
// ReasoningEfforts liste les valeurs proposées par l'UI pour REASONING_EFFORT.
// "" (auto) n'y figure pas : c'est l'absence de réglage.
//
// Aucun gabarit ne les connaît toutes — gpt-oss lit low/medium/high, Qwen3.8 lit
// low/medium/xhigh — et certains REFUSENT celles qu'ils ne connaissent pas (voir
// llm_effort.go, qui traduit alors vers le niveau accepté le plus proche). La
// liste reste donc l'union des niveaux courants plutôt que le plus petit
// dénominateur commun, sinon le maximum d'un modèle est hors d'atteinte.
var ReasoningEfforts = []string{"none", "low", "medium", "high", "xhigh"}
// reasoningEffortValue normalise REASONING_EFFORT en une valeur envoyable telle
// quelle à llama-server, ou "" pour ne rien envoyer du tout.
//
// llama-server accepte `reasoning_effort` dans /v1/chat/completions : la valeur
// `none` coupe le raisonnement, toute autre valeur est simplement passée au
// gabarit jinja du modèle. Un gabarit qui ne la lit pas l'ignore sans erreur ;
// un gabarit qui la VALIDE, lui, peut lever une exception jinja et faire
// répondre 500 à llama-server (Qwen3.8 sur « high »). Le repli n'est pas ici —
// on ne sait pas d'avance ce que le gabarit accepte — mais dans llm_effort.go,
// qui apprend la réponse du refus lui-même et rejoue le tour.
func reasoningEffortValue(v string) string {
switch s := strings.ToLower(strings.TrimSpace(v)); s {
case "", "auto", "default":
return "" // rien envoyé : le gabarit garde son comportement
case "off", "false", "0", "no", "disable", "disabled", "none":
return "none"
case "low", "medium", "high":
return s
default:
// Valeur inconnue (éditée à la main) : on ne l'invente pas, on la laisse
// passer au gabarit, qui l'ignorera s'il ne la connaît pas.
return s
}
}
// reasoningTemplateKwargs construit les `chat_template_kwargs` à joindre à une
// requête de chat pour que « aucune » coupe VRAIMENT le raisonnement.
//
// Pourquoi ce doublon avec `reasoning_effort` : ce champ n'agit que sur les
// gabarits qui le LISENT (gpt-oss et apparentés). Un modèle hybride à la Qwen3
// ne connaît que `enable_thinking` — on lui envoie `reasoning_effort: none`, son
// gabarit l'ignore, et il continue de réfléchir alors que l'interface affiche
// « aucune ». Les llama-server récents traduisent eux-mêmes `none` en
// `enable_thinking=false`, mais pas les binaires plus anciens, qui laissent
// simplement tomber le champ.
//
// D'où la règle : quand le raisonnement doit être coupé, on le dit dans la
// langue que TOUS les gabarits comprennent. `chat_template_kwargs` est passé tel
// quel au gabarit jinja ; une clé qu'il n'utilise pas est ignorée sans erreur.
// Même procédé que la compaction (chat_compact.go), qui coupe déjà la réflexion
// de cette façon.
//
// off = REASONING explicitement à off. Clé absente ≠ off : sans consigne on ne
// touche à rien, le gabarit garde son défaut.
// Renvoie nil quand il n'y a rien à imposer.
func reasoningTemplateKwargs(off bool, effort string) map[string]any {
if off || effort == "none" {
return map[string]any{"enable_thinking": false}
}
if effort != "" {
// Niveau explicite : les vieux binaires ne relaient pas le champ de haut
// niveau au gabarit, celui-ci passe toujours.
return map[string]any{"reasoning_effort": effort}
}
return nil
}
// reasoningExplicitlyOff dit si REASONING interdit le raisonnement. Une clé
// absente ou vide n'est PAS une interdiction : c'est l'absence de consigne.
func reasoningExplicitlyOff(v string) bool {
return strings.TrimSpace(v) != "" && !reasoningActive(v)
}
// detectQuant returns the quantization tag for a preset: an explicit QUANT= line
// (manual override, with or without a leading '#') wins; otherwise it is
// auto-detected from the MODEL= filename. Returns "" when unknown.
@@ -128,38 +201,70 @@ func modelFromPresetContent(content string) string {
// n'effacer que la première laissait des dizaines de Go de fichiers que plus
// rien ne référence, et que rien ne sait plus supprimer depuis l'interface (les
// tranches suivantes n'y apparaissent pas).
func deleteModelFile(name string) error {
//
// Renvoie le nombre d'octets réellement libérés — l'interface le dit, et
// « 41,2 Go libérés » est la seule confirmation qui vaille quelque chose après
// un ménage de disque.
//
// Un modèle EN SERVICE n'est jamais effacé : le fichier reste mappé par
// llama-server (la place n'est donc même pas rendue) et le prochain démarrage
// meurt sur un .gguf absent, sans que rien n'ait prévenu.
func deleteModelFile(name string) (int64, error) {
p, err := resolveModelPath(name)
if err != nil {
return err
return 0, err
}
if _, active := modelUsers(name); active {
return 0, fmt.Errorf("%s", modelInServiceMsg)
}
freed := modelFamilySize(p)
if err := os.Remove(p); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("modèle introuvable: %s", filepath.Base(p))
return 0, fmt.Errorf("modèle introuvable: %s", filepath.Base(p))
}
return err
return 0, err
}
dir := filepath.Dir(p)
for _, n := range shardFamily(filepath.Base(p)) {
_ = os.Remove(filepath.Join(dir, n)) // déjà supprimée ou absente = rien à faire
}
return nil
return freed, nil
}
// handleModelDelete deletes a single .gguf from LOKI_HOME.
// handleModelDelete efface un .gguf d'un des dossiers de modèles déclarés.
//
// Deux garde-fous, et un seul est un refus définitif :
//
// - modèle en service → refus net (deleteModelFile), il faut basculer d'abord ;
// - modèle nommé par un ou plusieurs presets → 409 avec la LISTE des presets
// concernés. L'interface la montre, l'utilisateur tranche, et renvoie la
// même requête avec force:true. Refuser tout court transformerait le
// nettoyage en impasse — c'est exactement le travers qu'on corrige ici.
func handleModelDelete(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Name string `json:"name"`
Force bool `json:"force"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if err := deleteModelFile(req.Name); err != nil {
users, active := modelUsers(req.Name)
if active {
sendJSON(w, 409, map[string]any{"ok": false, "error": modelInServiceMsg, "active": true})
return
}
if len(users) > 0 && !req.Force {
sendJSON(w, 409, map[string]any{"ok": false, "used": users,
"error": "modèle utilisé par : " + strings.Join(users, ", ")})
return
}
freed, err := deleteModelFile(req.Name)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
sendJSON(w, 200, map[string]any{"ok": true, "freed": freed, "used": users})
}
// ---- Hugging Face downloads -------------------------------------------------
@@ -309,7 +414,16 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
}
if len(todoURLs) == 0 {
dlMu.Unlock()
sendJSON(w, 409, map[string]any{"ok": false, "error": "le modèle existe déjà: " + name})
// Rien à faire : toutes les tranches sont là. Ce n'était pas une erreur,
// c'était le résultat voulu — le modèle est installé. On répond ok avec la
// valeur à écrire dans MODEL=, l'interface le sélectionne et enchaîne la
// file (le projecteur vision, par exemple) au lieu de s'arrêter en rouge.
value, _, _ := modelPresence(name, req.Dir)
if value == "" {
value = modelPickerValue(filepath.Dir(dests[0]), name)
}
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "exists": true,
"value": value, "parts": 0})
return
}
ctx, cancel := context.WithCancel(context.Background())
@@ -337,6 +451,13 @@ func checkDiskSpace(dir string, size int64) error {
if size <= 0 || free < 0 {
return nil
}
// Système de fichiers dont statfs approxime (FUSE/shfs d'Unraid, overlay,
// réseau) : le chiffre reste affiché, mais il ne peut plus REFUSER un
// téléchargement. Vécu : « 11,9 Go libres » annoncés sur un partage Unraid
// qui en avait des centaines, et l'installation du modèle bloquée.
if !diskFreeReliable(dir) {
return nil
}
if free < size+dlSpaceMargin {
return fmt.Errorf("espace insuffisant sur %s : %s libres, %s nécessaires", dir, humanBytes(free), humanBytes(size+dlSpaceMargin))
}
@@ -382,6 +503,21 @@ func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) {
// puis échouer au deux tiers du transfert.
urls, names := shardURLSet(dlURL, name)
name = names[0]
// Déjà sur le disque : on ne sonde même pas Hugging Face. C'est le cas qui
// mettait l'interface dans une impasse — un modèle gardé après la suppression
// de son preset ne pouvait plus être re-choisi qu'en le retéléchargeant, et
// le téléchargement refusait d'écraser. Il n'y a rien à télécharger : il y a
// un modèle à SÉLECTIONNER, et l'UI a besoin de sa valeur MODEL= pour ça.
// Cherché dans TOUS les dossiers déclarés, pas seulement la destination
// choisie : re-télécharger 40 Go sur le SSD parce que le fichier est sur le
// disque externe déjà déclaré n'aide personne — le sélecteur, lui, le trouve
// où qu'il soit.
if value, path, ok := modelPresence(name, ""); ok {
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "dir": filepath.Dir(path),
"exists": true, "value": value, "size": modelFamilySize(path),
"free": diskFree(dir), "free_exact": diskFreeReliable(dir), "enough": true, "parts": len(urls)})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
var total int64
@@ -393,7 +529,8 @@ func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) {
}
total += n
}
out := map[string]any{"ok": true, "filename": name, "dir": dir, "size": total, "free": diskFree(dir), "enough": true, "parts": len(urls)}
out := map[string]any{"ok": true, "filename": name, "dir": dir, "size": total,
"free": diskFree(dir), "free_exact": diskFreeReliable(dir), "enough": true, "parts": len(urls)}
if err := checkDiskSpace(dir, total); err != nil {
out["enough"] = false
out["error"] = err.Error()
@@ -408,8 +545,12 @@ func dlRequest(ctx context.Context, dlURL, rng string) (*http.Request, error) {
if err != nil {
return nil, err
}
// HF gated/private repos may need a token; reuse the same key store if set.
if k := os.Getenv("HF_TOKEN"); k != "" {
// Dépôt gated/privé : le jeton (réglage de l'interface, sinon HF_TOKEN) est
// ce qui débloque le transfert. Il n'accompagne QUE les liens Hugging Face :
// un lien collé vers un autre hébergeur n'a aucune raison de recevoir un
// secret, et le CDN vers lequel HF redirige est signé, donc n'en a pas besoin
// non plus (Go retire l'en-tête au changement de domaine).
if k := hfToken(); k != "" && isHFHost(req.URL.Host) {
req.Header.Set("Authorization", "Bearer "+k)
}
req.Header.Set("User-Agent", "loki/"+Version)
@@ -433,6 +574,93 @@ func contentRangeTotal(v string) int64 {
return n
}
// dlSourceError traduit un refus HTTP en phrase qui dit quoi faire.
//
// Vécu : un dépôt Hugging Face « gated » (conditions à accepter avant de
// télécharger) répond 200 sur son arborescence — Loki liste donc tous ses
// quants, avec leur verdict mémoire — puis 401 sur CHAQUE .gguf. « HTTP 401
// depuis la source » ne dit alors ni que le dépôt est verrouillé, ni qu'il faut
// un jeton, ni où l'accepter : l'utilisateur voit un modèle proposé comme
// installable qui échoue sans raison.
//
// Hugging Face, lui, le dit — dans l'en-tête X-Error-Code (GatedRepo,
// RepoNotFound, EntryNotFound…). On le traduit plutôt que de le recopier :
// l'interface est en français, et le message d'origine (« Please log in »)
// parle d'une session de navigateur qui n'existe pas ici.
func dlSourceError(resp *http.Response, dlURL string) error {
code := resp.Header.Get("X-Error-Code")
switch resp.StatusCode {
case 401, 403:
return fmt.Errorf("%s%s", dlAccessReason(code, hfRepoFromURL(dlURL)), dlTokenHint())
case 404:
switch code {
case "EntryNotFound":
// Nommer le fichier : sur un modèle en fragments, c'est souvent UN
// fragment qui manque — certains dépôts n'en publient que six sur
// sept et livrent la table PLE à part, à lier soi-même — et sans
// le nom, on cherche une révision réécrite qui n'existe pas.
return fmt.Errorf("fichier %s absent du dépôt (HTTP 404) — dépôt réorganisé depuis la copie du lien, ou fragment jamais publié (table PLE livrée à part ?)", dlFileLabel(dlURL))
case "RevisionNotFound":
return fmt.Errorf("révision introuvable dans le dépôt (HTTP 404)")
}
return fmt.Errorf("lien introuvable (HTTP 404)")
case 416:
return fmt.Errorf("la source refuse la plage d'octets demandée (HTTP 416) — fichier modifié pendant le transfert ?")
case 429:
return fmt.Errorf("trop de requêtes vers la source (HTTP 429) — réessaie dans quelques minutes")
}
if resp.StatusCode >= 500 {
return fmt.Errorf("la source est en panne (HTTP %d) — réessaie plus tard", resp.StatusCode)
}
return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
}
// dlFileLabel : « nom.gguf » entre guillemets pour un message, ou vide si le
// lien n'a pas de nom de fichier lisible.
func dlFileLabel(dlURL string) string {
u, err := url.Parse(dlURL)
if err != nil {
return ""
}
name := path.Base(u.Path)
if name == "" || name == "/" || name == "." {
return ""
}
return "« " + name + " »"
}
// dlAccessReason nomme la raison du refus. repo vide = source hors Hugging Face
// (lien direct vers un autre hébergeur) : on ne parle alors pas de conditions à
// accepter, qui n'existent que là-bas.
func dlAccessReason(code, repo string) string {
switch code {
case "GatedRepo":
if repo == "" {
return "dépôt à accès restreint : ses conditions doivent être acceptées sur huggingface.co"
}
return "dépôt à accès restreint : accepte ses conditions sur huggingface.co/" + repo
case "RepoNotFound":
if repo == "" {
return "dépôt privé ou inexistant"
}
return "dépôt privé ou inexistant : " + repo
}
if repo != "" {
return "accès refusé par Hugging Face sur " + repo
}
return "accès refusé par la source"
}
// dlTokenHint complète la raison par l'état du jeton. Deux situations opposées
// se cachent derrière le même 401 : pas de jeton du tout, ou un jeton qui n'a
// pas accès à CE dépôt — et le geste à faire n'est pas le même.
func dlTokenHint() string {
if !hfTokenSet() {
return " — puis renseigne le jeton Hugging Face (réglage « Jeton Hugging Face », ou variable d'environnement HF_TOKEN)"
}
return " — le jeton Hugging Face enregistré n'y donne pas accès (expiré, ou conditions non acceptées avec ce compte)"
}
// dlProbe asks the server for the first byte to learn the total size and
// whether ranges are supported (206 + Content-Range).
func dlProbe(ctx context.Context, dlURL string) (total int64, ranged bool, err error) {
@@ -456,7 +684,7 @@ func dlProbe(ctx context.Context, dlURL string) (total int64, ranged bool, err e
// Server ignored the Range: single stream, ContentLength is the size.
return resp.ContentLength, false, nil
default:
return 0, false, fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
return 0, false, dlSourceError(resp, dlURL)
}
}
@@ -682,8 +910,9 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
continue
}
if resp.StatusCode != 200 && resp.StatusCode != 206 {
err := dlSourceError(resp, dlURL)
resp.Body.Close()
return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
return err
}
if resp.StatusCode == 200 && pos > start {
// Resume refused: the body restarts from 0, rewind our bookkeeping.
+96
View File
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
@@ -135,3 +136,98 @@ func TestRunDownloadParallelAndFallback(t *testing.T) {
}
}
}
// Un dépôt Hugging Face verrouillé répond 401 sur le .gguf alors que son
// arborescence se lit sans jeton. « HTTP 401 depuis la source » n'apprenait
// rien : le message doit nommer le verrou, le dépôt, et le geste à faire.
func TestDLSourceErrorExplainsGatedRepo(t *testing.T) {
const gated = "https://huggingface.co/orcarouter/Qwen3.8-27B-Uncensored-GGUF/resolve/main/m.gguf"
resp := func(status int, code string) *http.Response {
r := &http.Response{StatusCode: status, Header: http.Header{}}
if code != "" {
r.Header.Set("X-Error-Code", code)
}
return r
}
for _, c := range []struct {
name string
token string
resp *http.Response
url string
want []string
absent []string
}{
{
name: "gated sans jeton", resp: resp(401, "GatedRepo"), url: gated,
want: []string{"accès restreint", "orcarouter/Qwen3.8-27B-Uncensored-GGUF", "HF_TOKEN"},
},
{
name: "gated avec jeton refusé", token: "hf_xxx", resp: resp(401, "GatedRepo"), url: gated,
want: []string{"accès restreint", "n'y donne pas accès"},
absent: []string{"renseigne le jeton"},
},
{
name: "dépôt privé ou absent", resp: resp(401, "RepoNotFound"), url: gated,
want: []string{"privé ou inexistant", "HF_TOKEN"},
},
{
name: "fichier absent", resp: resp(404, "EntryNotFound"), url: gated,
want: []string{"« m.gguf » absent du dépôt", "404"},
},
{
// Dépôt qui publie six fragments sur sept (table PLE livrée à part) :
// le message doit NOMMER le fragment manquant, sinon on cherche une
// révision réécrite qui n'existe pas.
name: "fragment absent", resp: resp(404, "EntryNotFound"),
url: "https://huggingface.co/x/y-GGUF/resolve/main/Q2_K/y-Q2_K-00007-of-00007.gguf",
want: []string{"« y-Q2_K-00007-of-00007.gguf » absent du dépôt", "fragment jamais publié"},
},
{
name: "source hors Hugging Face", resp: resp(403, ""), url: "https://example.com/m.gguf",
want: []string{"accès refusé par la source"},
absent: []string{"huggingface.co"},
},
{
name: "panne de la source", resp: resp(503, ""), url: gated,
want: []string{"en panne", "503"},
},
{
name: "code inconnu", resp: resp(418, ""), url: gated,
want: []string{"HTTP 418 depuis la source"},
},
} {
t.Run(c.name, func(t *testing.T) {
t.Setenv("HF_TOKEN", c.token)
got := dlSourceError(c.resp, c.url).Error()
for _, w := range c.want {
if !strings.Contains(got, w) {
t.Errorf("message %q ne contient pas %q", got, w)
}
}
for _, a := range c.absent {
if strings.Contains(got, a) {
t.Errorf("message %q contient %q alors qu'il ne devrait pas", got, a)
}
}
})
}
}
// La sonde est le premier appel réseau d'une installation : c'est elle qui doit
// remonter le refus expliqué, pas un code HTTP nu.
func TestDLProbeSurfacesExplainedError(t *testing.T) {
t.Setenv("HF_TOKEN", "")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Error-Code", "GatedRepo")
w.WriteHeader(401)
}))
defer srv.Close()
_, _, err := dlProbe(context.Background(), srv.URL+"/m.gguf")
if err == nil {
t.Fatal("un 401 doit faire échouer la sonde")
}
if !strings.Contains(err.Error(), "HF_TOKEN") {
t.Errorf("message %q sans indication sur le jeton", err)
}
}
+165
View File
@@ -0,0 +1,165 @@
package loki
// backend_models_refs.go — QUI référence un .gguf, et peut-on l'effacer ?
//
// Vécu qui a produit ce fichier : un modèle téléchargé depuis l'éditeur d'un
// preset, le preset supprimé (sans cocher « supprimer aussi le .gguf »), et
// 40 Go de fichier plus rien pour les reprendre — la route /api/models/delete
// existait mais AUCUN bouton ne l'appelait. Supprimer un modèle depuis
// l'interface demande deux choses : savoir qui s'en sert (le moteur en service,
// d'autres presets), et le dire AVANT d'effacer.
import (
"os"
"path/filepath"
"strings"
)
// modelRefIndex — l'index « ce fichier est référencé par… », construit en une
// seule passe. Demander « qui utilise ce modèle ? » fichier par fichier
// relisait tous les presets à chaque ligne de la liste des modèles.
//
// Les clés sont des chemins normalisés (normDir) : un preset peut nommer son
// modèle en chemin absolu et un autre en simple nom de fichier, c'est le même
// fichier sur le disque.
type modelRefIndex struct {
active map[string]bool // référencé par config.env — donc par le moteur qui tourne
presets map[string][]string // chemin -> noms d'affichage des presets
}
// modelRefKey normalise une valeur MODEL=/MMPROJ= en clé d'index, ou "" si elle
// ne désigne pas un .gguf. resolveModelPath accepte le nom simple comme le
// chemin absolu et les résout dans les dossiers déclarés — c'est exactement la
// règle qu'appliquera le moteur.
func modelRefKey(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
p, err := resolveServeModelPath(v)
if err != nil {
// Introuvable : on garde quand même une clé comparable, sinon deux
// presets qui nomment le même fichier absent paraîtraient distincts.
p = filepath.Join(modelsDir(), baseName(v))
}
return normDir(p)
}
// modelKeysOf liste les .gguf référencés par une configuration : le modèle, le
// projecteur vision, le brouillon (MODEL_DRAFT : une tête MTP effacée sous le
// moteur coupe la spéculation sans rien dire), et un --mmproj posé à la main
// dans EXTRA_ARGS (les presets d'avant la clé MMPROJ l'écrivaient là).
func modelKeysOf(env map[string]string) []string {
vals := []string{env["MODEL"], env["MMPROJ"], env["MODEL_DRAFT"]}
args := splitArgs(env["EXTRA_ARGS"])
for i, a := range args {
if a == "--mmproj" && i+1 < len(args) {
vals = append(vals, args[i+1])
}
}
out := []string{}
for _, v := range vals {
if k := modelRefKey(v); k != "" {
out = append(out, k)
}
}
return out
}
// buildModelRefIndex lit la configuration active et tous les presets.
func buildModelRefIndex() modelRefIndex {
ix := modelRefIndex{active: map[string]bool{}, presets: map[string][]string{}}
for _, k := range modelKeysOf(ReadConfig()) {
ix.active[k] = true
}
list, err := ListPresets()
if err != nil {
return ix
}
for _, p := range list {
content, err := ReadPreset(p.ID)
if err != nil {
continue
}
seen := map[string]bool{}
for _, k := range modelKeysOf(parseEnv(content)) {
if seen[k] {
continue // MODEL et MMPROJ identiques : un seul « utilisé par »
}
seen[k] = true
ix.presets[k] = append(ix.presets[k], p.Name)
}
}
return ix
}
// lookup renvoie les presets qui référencent path et s'il est en service.
func (ix modelRefIndex) lookup(path string) (presets []string, active bool) {
k := normDir(path)
return ix.presets[k], ix.active[k]
}
// modelUsers répond pour UN modèle (nom de fichier ou chemin).
func modelUsers(name string) (presets []string, active bool) {
k := modelRefKey(name)
if k == "" {
return nil, false
}
return buildModelRefIndex().lookup(k)
}
// modelInServiceErr est le refus opposé à la suppression d'un modèle que le
// moteur a ouvert. Effacer le .gguf sous un llama-server qui tourne ne libère
// même pas la place (le fichier reste mappé) et rend le prochain démarrage
// impossible sans rien dire.
const modelInServiceMsg = "modèle en service — bascule sur un autre preset avant de le supprimer"
// modelFamilySize additionne ce que la suppression libérera réellement
// (toutes les tranches présentes).
func modelFamilySize(p string) int64 {
return shardFamilySize(filepath.Dir(p), filepath.Base(p))
}
// modelPresence dit si un modèle est DÉJÀ sur le disque, et sous quelle valeur
// l'écrire dans MODEL=. dir restreint la recherche à ce dossier (celui visé par
// un téléchargement) ; vide = tous les dossiers déclarés.
//
// C'est ce qui transforme « le modèle existe déjà » — un cul-de-sac rouge au
// bout d'un clic sur un quant — en « déjà là, je le sélectionne ».
func modelPresence(name, dir string) (value, path string, ok bool) {
base := baseName(strings.TrimSpace(name))
if base == "" || !strings.HasSuffix(strings.ToLower(base), ".gguf") {
return "", "", false
}
dirs := modelDirs()
if strings.TrimSpace(dir) != "" {
if d, err := resolveDownloadDir(dir); err == nil {
dirs = []string{d}
}
}
for _, d := range dirs {
p := filepath.Join(d, base)
if st, err := os.Stat(p); err != nil || st.IsDir() {
continue
}
// Famille incomplète = modèle inutilisable : on ne prétend pas qu'il est
// là, l'appelant doit pouvoir relancer le téléchargement des tranches
// manquantes (handleModelDownload les reprend une par une).
if len(shardFamilyMissing(d, base)) > 0 {
continue
}
return modelPickerValue(d, base), p, true
}
return "", "", false
}
// modelPickerValue est la valeur à écrire dans MODEL= pour un fichier donné :
// le simple nom quand il vit dans le dossier de téléchargement de Loki (le
// preset reste alors portable d'une machine à l'autre), le chemin complet
// ailleurs. Même règle que la liste /api/models.
func modelPickerValue(dir, base string) string {
if normDir(dir) == normDir(modelsDir()) {
return base
}
return filepath.Join(dir, base)
}
+254
View File
@@ -0,0 +1,254 @@
package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// writeModel pose un .gguf factice dans dir et renvoie son chemin.
func writeModel(t *testing.T, dir, name string, size int) string {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, name)
if err := os.WriteFile(p, make([]byte, size), 0o644); err != nil {
t.Fatal(err)
}
return p
}
// Le scénario complet du rapport : un modèle téléchargé depuis un preset, le
// preset supprimé sans son .gguf, et plus rien pour reprendre le fichier.
// Après ce correctif il doit être A) retrouvé par le sélecteur, B) effaçable.
func TestModeleOrphelinResteUtilisableEtEffaçable(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "modele.gguf", 1024)
// A) Présent sur le disque, sous la valeur qu'on écrira dans MODEL=.
value, path, ok := modelPresence("modele.gguf", "")
if !ok {
t.Fatal("modèle présent non détecté — le téléchargement resterait un cul-de-sac")
}
if value != "modele.gguf" {
t.Fatalf("valeur MODEL= = %q, attendu le simple nom de fichier", value)
}
if path != filepath.Join(modelsDir(), "modele.gguf") {
t.Fatalf("chemin = %q", path)
}
// B) Plus personne ne le référence : il s'efface sans force.
users, active := modelUsers("modele.gguf")
if active || len(users) != 0 {
t.Fatalf("modèle orphelin annoncé utilisé : actif=%v presets=%v", active, users)
}
freed, err := deleteModelFile("modele.gguf")
if err != nil {
t.Fatalf("suppression refusée : %v", err)
}
if freed != 1024 {
t.Fatalf("octets libérés = %d, attendu 1024", freed)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("le fichier est toujours là")
}
}
// Le modèle que le moteur a ouvert ne s'efface pas : la place ne serait même pas
// rendue, et le prochain démarrage mourrait sur un .gguf absent.
func TestSuppressionRefuseeSurLeModeleEnService(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "servi.gguf", 8)
if err := WriteConfig(map[string]string{"MODEL": "servi.gguf"}); err != nil {
t.Fatal(err)
}
if _, active := modelUsers("servi.gguf"); !active {
t.Fatal("modèle de la configuration active non reconnu")
}
if _, err := deleteModelFile("servi.gguf"); err == nil {
t.Fatal("le modèle en service a été supprimé")
}
if _, err := os.Stat(filepath.Join(modelsDir(), "servi.gguf")); err != nil {
t.Fatal("fichier supprimé malgré le refus")
}
}
// Un modèle nommé par des presets n'est pas bloqué : il est SIGNALÉ, et
// force:true tranche. Refuser tout court recréerait l'impasse qu'on corrige.
func TestSuppressionSignaleLesPresetsPuisObeitAForce(t *testing.T) {
testHome(t)
p := writeModel(t, modelsDir(), "partage.gguf", 16)
if _, err := SavePreset("", "Rapide", "MODEL=\"partage.gguf\"\nCTX=4096\n"); err != nil {
t.Fatal(err)
}
if _, err := SavePreset("", "Long", "MODEL=\"partage.gguf\"\nCTX=131072\n"); err != nil {
t.Fatal(err)
}
users, active := modelUsers("partage.gguf")
if active {
t.Fatal("aucune configuration active ne devrait référencer ce modèle")
}
if len(users) != 2 {
t.Fatalf("presets référents = %v, attendu les deux", users)
}
post := func(body string) (int, map[string]any) {
r := httptest.NewRequest("POST", "/api/models/delete", strings.NewReader(body))
w := httptest.NewRecorder()
handleModelDelete(w, r)
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out
}
code, out := post(`{"name":"partage.gguf"}`)
if code != 409 {
t.Fatalf("code = %d, attendu 409 (presets référents)", code)
}
if len(out["used"].([]any)) != 2 {
t.Fatalf("la réponse doit NOMMER les presets concernés : %v", out)
}
if _, err := os.Stat(p); err != nil {
t.Fatal("fichier supprimé alors que la confirmation n'a pas eu lieu")
}
if code, out = post(`{"name":"partage.gguf","force":true}`); code != 200 {
t.Fatalf("code = %d avec force:true — %v", code, out)
}
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatal("force:true n'a pas supprimé le fichier")
}
}
// MMPROJ et un --mmproj resté dans EXTRA_ARGS comptent aussi : effacer le
// projecteur du modèle en service casse la vision sans rien dire.
func TestProjecteurCompteCommeReference(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "mmproj-F16.gguf", 4)
writeModel(t, modelsDir(), "vieux-mmproj.gguf", 4)
if err := WriteConfig(map[string]string{"MODEL": "m.gguf", "MMPROJ": "mmproj-F16.gguf"}); err != nil {
t.Fatal(err)
}
if _, active := modelUsers("mmproj-F16.gguf"); !active {
t.Fatal("MMPROJ= non compté comme référence")
}
if _, err := SavePreset("", "Vision", `MODEL="m.gguf"`+"\n"+`EXTRA_ARGS="--mmproj vieux-mmproj.gguf --flash-attn"`+"\n"); err != nil {
t.Fatal(err)
}
users, _ := modelUsers("vieux-mmproj.gguf")
if len(users) != 1 || users[0] != "Vision" {
t.Fatalf("--mmproj d'EXTRA_ARGS non compté : %v", users)
}
// La tête MTP publiée à part aussi (MODEL_DRAFT).
writeModel(t, modelsDir(), "mtp-Qwen3.6-27B.gguf", 4)
if _, err := SavePreset("", "MTP", `MODEL="m.gguf"`+"\n"+`SPEC=auto`+"\n"+`MODEL_DRAFT="mtp-Qwen3.6-27B.gguf"`+"\n"); err != nil {
t.Fatal(err)
}
if users, _ := modelUsers("mtp-Qwen3.6-27B.gguf"); len(users) != 1 || users[0] != "MTP" {
t.Fatalf("MODEL_DRAFT= non compté : %v", users)
}
}
// Une famille de tranches incomplète n'est pas « déjà là » : il reste à
// télécharger, et prétendre le contraire donnerait un moteur qui meurt sur un
// tenseur introuvable.
func TestFamilleIncompleteNestPasPresente(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "gros-00001-of-00002.gguf", 4)
if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); ok {
t.Fatal("famille incomplète annoncée présente")
}
writeModel(t, modelsDir(), "gros-00002-of-00002.gguf", 4)
if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); !ok {
t.Fatal("famille complète non détectée")
}
}
// Un modèle rangé dans un dossier déclaré ailleurs (disque externe) est trouvé
// lui aussi, et sous son chemin complet — un simple nom ne le désignerait pas.
func TestPresenceDansUnDossierDeclare(t *testing.T) {
home := testHome(t)
ext := filepath.Join(home, "externe")
writeModel(t, ext, "ailleurs.gguf", 32)
if _, _, ok := modelPresence("ailleurs.gguf", ""); ok {
t.Fatal("dossier non déclaré : le modèle ne doit pas être visible")
}
if err := saveExtraModelDirs([]string{ext}); err != nil {
t.Fatal(err)
}
value, _, ok := modelPresence("ailleurs.gguf", "")
if !ok {
t.Fatal("modèle d'un dossier déclaré non détecté")
}
if value != filepath.Join(ext, "ailleurs.gguf") {
t.Fatalf("valeur MODEL= = %q, attendu le chemin complet", value)
}
}
// /api/models doit marquer « dossier loki » le dossier de téléchargement et en
// donner le SIMPLE NOM : c'est ce que resolveModelPath retrouve, et c'est ce qui
// permet au sélecteur de reconnaître un MODEL=modele.gguf écrit à la main.
func TestListeModelesNommeLeDossierDeLoki(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "local.gguf", 64)
if err := WriteConfig(map[string]string{"MODEL": "local.gguf"}); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handleModels(w, httptest.NewRequest("GET", "/api/models", nil))
var out []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if len(out) != 1 {
t.Fatalf("liste = %v", out)
}
if out[0]["home"] != true {
t.Fatal("le dossier de téléchargement n'est pas marqué « dossier loki »")
}
if out[0]["value"] != "local.gguf" {
t.Fatalf("value = %v, attendu le simple nom de fichier", out[0]["value"])
}
if out[0]["active"] != true {
t.Fatal("le modèle en service doit être signalé comme tel")
}
}
// Télécharger un modèle DÉJÀ sur le disque n'est pas une erreur : c'est le
// résultat voulu. Les deux routes doivent répondre « ok, exists » avec la valeur
// à écrire dans MODEL= — sinon l'interface s'arrête en rouge sur « le modèle
// existe déjà » et il devient impossible de refaire un preset dessus.
func TestTelechargerUnModelePresentLeSelectionne(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "deja.gguf", 2048)
const url = "https://huggingface.co/auteur/depot/resolve/main/deja.gguf"
call := func(h func(w http.ResponseWriter, r *http.Request), path string) (int, map[string]any) {
body := `{"url":"` + url + `","dir":""}`
w := httptest.NewRecorder()
h(w, httptest.NewRequest("POST", path, strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out
}
// La sonde ne doit même pas sortir sur le réseau : elle répond depuis le disque.
code, out := call(handleModelDownloadProbe, "/api/models/download/probe")
if code != 200 || out["ok"] != true || out["exists"] != true {
t.Fatalf("sonde : code=%d out=%v", code, out)
}
if out["value"] != "deja.gguf" {
t.Fatalf("sonde : value = %v, attendu le nom à mettre dans MODEL=", out["value"])
}
code, out = call(handleModelDownload, "/api/models/download")
if code != 200 || out["ok"] != true || out["exists"] != true {
t.Fatalf("téléchargement : code=%d out=%v", code, out)
}
if out["value"] != "deja.gguf" {
t.Fatalf("téléchargement : value = %v", out["value"])
}
}
+5 -1
View File
@@ -106,9 +106,13 @@ func TestDeleteModelFileEmporteLesTranches(t *testing.T) {
t.Fatal(err)
}
}
if err := deleteModelFile("m-00001-of-00002.gguf"); err != nil {
freed, err := deleteModelFile("m-00001-of-00002.gguf")
if err != nil {
t.Fatal(err)
}
if freed != 2 {
t.Fatalf("octets libérés = %d, attendu 2 (les deux tranches d'un octet)", freed)
}
for _, n := range names[:2] {
if _, err := os.Stat(filepath.Join(dir, n)); !os.IsNotExist(err) {
t.Fatalf("tranche %s non supprimée", n)
+11 -1
View File
@@ -440,8 +440,18 @@ func prebuiltInstall(logf, phasef func(string)) (string, error) {
// downloadWithProgress télécharge url vers dest en journalisant la progression
// par tranches de ~25 Mo.
func downloadWithProgress(url, dest string, total int64, logf func(string)) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
return downloadRequest(req, dest, total, logf)
}
// downloadRequest est la même chose pour une requête déjà construite — un
// registre OCI exige un en-tête d'autorisation, même en lecture anonyme.
func downloadRequest(req *http.Request, dest string, total int64, logf func(string)) error {
client := &http.Client{Timeout: 0}
resp, err := client.Get(url)
resp, err := client.Do(req)
if err != nil {
return err
}
+175 -6
View File
@@ -113,7 +113,69 @@ func ListPresets() ([]Preset, error) {
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
return applyPresetOrder(out), nil
}
// presetOrderKey : ordre d'affichage choisi par glisser-déposer dans l'UI
// (repris d'AJEAN v0.10.9), une liste d'IDs persistée en base.
const presetOrderKey = "preset_order"
func loadPresetOrder() []string {
var ids []string
getJSON(bkState, presetOrderKey, &ids)
return ids
}
func savePresetOrder(ids []string) error { return putJSON(bkState, presetOrderKey, ids) }
// applyPresetOrder réordonne `list` (déjà trié par nom) selon l'ordre stocké :
// d'abord les presets classés dans l'ordre choisi, puis les non classés
// (fraîchement créés) dans leur ordre alphabétique. Tri STABLE.
func applyPresetOrder(list []Preset) []Preset {
order := loadPresetOrder()
if len(order) == 0 {
return list
}
pos := map[string]int{}
for i, id := range order {
pos[id] = i
}
sort.SliceStable(list, func(i, j int) bool {
pi, oki := pos[list[i].ID]
pj, okj := pos[list[j].ID]
if oki && okj {
return pi < pj
}
if oki != okj {
return oki // un preset classé passe avant un non classé
}
return false // deux non classés : l'ordre alphabétique reste
})
return list
}
// presetCtx : la taille de contexte d'un preset, formatée pour la pastille de
// la liste (« 32K », « 128K », « 1M »). CTX absent = 32768, le défaut de serve.
func presetCtx(content string) string {
raw := strings.TrimSpace(parseEnv(content)["CTX"])
if raw == "" {
raw = "32768"
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return ""
}
// Multiple de 1024 (32768, 131072…) : écrit comme on le pense, « 32K »,
// « 128K » ; sinon décimal, comme on le tape (40000 → « 40K »).
switch {
case n >= 1024 && n%1024 == 0 && n < 1<<20:
return strconv.Itoa(n/1024) + "K"
case n >= 1000000:
return strings.TrimSuffix(strconv.FormatFloat(float64(n)/1000000, 'f', 1, 64), ".0") + "M"
case n >= 1000:
return strconv.Itoa((n+500)/1000) + "K"
}
return strconv.Itoa(n)
}
// uniquePresetID derives a unique filename id from a display name, appending
@@ -189,7 +251,13 @@ func safePresetPath(name string) (string, error) {
// version effaçait la clé, et le moteur repartait sur le défaut « toutes les
// interfaces » : une machine volontairement fermée se rouvrait toute seule au
// premier changement de preset.
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST"}
// CUDA_LAUNCH_QUEUES (files de lancement CUDA, voir launchQueuesEnv) suit la
// même logique : un « off » posé parce que CETTE machine bloque ne doit pas
// disparaître à la bascule suivante.
// SLOT_PERSIST (état du slot gardé à la bascule, backend_serve_persist.go) est
// par nature un réglage de bascule : posé sur A seulement, il disparaîtrait en
// passant sur B, dont le lancement effacerait l'état gardé de A.
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST", "CUDA_LAUNCH_QUEUES", "SLOT_PERSIST"}
// softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit
// pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un
@@ -199,7 +267,8 @@ var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VIS
// inconditionnellement écrasait cette valeur par l'ancienne (mono-GPU) : le
// modèle se retrouvait entièrement sur une seule carte et mourait sur
// « cudaMalloc failed: out of memory ». Le preset explicite gagne.
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true}
// CUDA_LAUNCH_QUEUES aussi : un preset peut l'imposer pour son modèle.
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true, "CUDA_LAUNCH_QUEUES": true, "SLOT_PERSIST": true}
// applyPresetFile installe le preset comme configuration active, en réinjectant
// les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être
@@ -225,17 +294,66 @@ func applyPresetFile(target string) error {
}
next[k] = v
}
// Le moteur suit la machine, pas le preset (voir presetImposesEngine).
if cur["BIN"] != "" && !presetImposesEngine(next["BIN"]) {
next["BIN"] = cur["BIN"]
}
return WriteConfig(next)
}
// presetImposesEngine : le BIN d'un preset a-t-il son mot à dire ?
//
// Le moteur est un réglage de MACHINE. Un preset créé avant une mise à jour du
// moteur (Réglages → Moteur, qui installe sous $LOKI_HOME/engine/) embarque
// encore le chemin de l'image, /app/llama-server ; l'appliquer — changer de
// modèle, une tâche planifiée — ramenait silencieusement ce vieux moteur, et
// un modèle qui chargeait la veille mourait sur « unknown model architecture »
// sans qu'aucun réglage visible ait bougé. Le moteur de l'image, un moteur
// téléchargé par Loki ou un précompilé ne sont donc jamais imposés par un
// preset : le courant reste.
//
// Un backend PERSONNALISÉ (compilé pour un modèle précis, backend_llamacpp.go :
// « un backend custom se choisit par modèle »), lui, est un vrai choix par
// modèle : le preset gagne.
func presetImposesEngine(p string) bool {
p = strings.TrimSpace(p)
if p == "" || engineOwns(p) || prebuiltOwns(p) {
return false
}
if img := strings.TrimSpace(os.Getenv("LOKI_ENGINE_BIN")); img != "" && samePath(p, img) {
return false
}
return true
}
// SwitchToPreset installe le preset et redémarre le service. Les réglages
// « appareil » (preservedKeys) sont conservés à travers la bascule.
func SwitchToPreset(target string) error {
if err := tuneGuard(); err != nil {
return err
}
// SLOT_PERSIST : relevé sur la configuration d'avant, gardé juste avant
// l'arrêt (llm_slotpersist.go). Sans la clé, ou hors du process web (rien
// n'y dit ce que porte le slot) : nil, rien ne se passe.
persist := slotPersistPrepare()
if err := applyPresetFile(target); err != nil {
return err
}
fmt.Printf("%s configuration <- %s\n", green("[ok]"), filepath.Base(target))
// Preset externe (backend_external.go) : rien à redémarrer, il n'a pas de
// MODEL et llama-server partirait en boucle de crash. On arrête au contraire
// le moteur encore en vie — le chat part vers l'API distante, garder le
// modèle en VRAM ne sert plus à rien.
if isExternalConfig(ReadConfig()) {
fmt.Println(dim("[info] preset externe — arrêt du moteur local"))
if !serviceIsActive() {
return nil
}
persist.save()
return serviceAction("stop")
}
fmt.Println(dim("[info] redémarrage du service..."))
persist.save()
return serviceAction("restart")
}
@@ -287,6 +405,10 @@ func cmdSwitch(args []string) error {
// mélangeaient donc aux options cochées pour le nouveau, et il fallait penser à
// tout nettoyer à la main. On repart d'une base vide : les valeurs non
// renseignées sont les défauts documentés (CTX 32768, NGL 999, BATCH 2048…).
//
// BIN n'est repris que s'il désigne un backend personnalisé (presetImposesEngine) :
// figer /app/llama-server dans chaque preset, c'est ce qui faisait perdre le
// moteur mis à jour à la première bascule.
var newPresetSeedKeys = []string{"BIN", "HOST", "PORT"}
// newPresetSeed renvoie la configuration de départ d'un preset créé depuis l'UI.
@@ -294,9 +416,11 @@ func newPresetSeed() map[string]string {
cur := ReadConfig()
seed := map[string]string{}
for _, k := range newPresetSeedKeys {
if v := cur[k]; v != "" {
seed[k] = v
v := cur[k]
if v == "" || (k == "BIN" && !presetImposesEngine(v)) {
continue
}
seed[k] = v
}
return seed
}
@@ -331,6 +455,41 @@ func SavePreset(id, name, content string) (string, error) {
return id, os.WriteFile(p, []byte(content), 0o644)
}
// SavePresetApplying enregistre un preset et, si c'était celui EN SERVICE,
// installe aussitôt la nouvelle version comme configuration active (mêmes
// règles qu'une bascule : applyPresetFile). Renvoie applied=true dans ce cas —
// l'appelant redémarre alors le moteur.
//
// Vécu : CTX passé à 100000 dans l'éditeur, « enregistré »… et la carte du
// chat qui affiche toujours 32768. Seul le fichier du preset avait changé ;
// config.env gardait l'ancienne valeur, le moteur tournait avec, et plus aucun
// preset n'était détecté actif (empreinte différente). Éditer le preset en
// service, c'est vouloir ces réglages-là tout de suite. Un preset inactif ou
// nouveau, lui, n'est qu'un fichier : rien ne bouge.
//
// « Actif » se décide AVANT l'écriture : c'est l'ancienne version du fichier qui
// porte l'empreinte de la configuration courante.
func SavePresetApplying(id, name, content string) (newID string, applied bool, err error) {
wasActive := false
if id != "" {
if old, rerr := ReadPreset(id); rerr == nil {
wasActive = presetFingerprint([]byte(old)) == configFingerprint(ReadConfig())
}
}
newID, err = SavePreset(id, name, content)
if err != nil || !wasActive {
return newID, false, err
}
p, err := safePresetPath(newID)
if err != nil {
return newID, false, err
}
if err := applyPresetFile(p); err != nil {
return newID, false, err
}
return newID, true, nil
}
// DeletePreset removes a preset by id; refuses if it is the active config.
func DeletePreset(id string) error {
p, err := safePresetPath(id)
@@ -344,7 +503,17 @@ func DeletePreset(id string) error {
if presetFingerprint(target) == configFingerprint(ReadConfig()) {
return fmt.Errorf("preset actif, switche d'abord")
}
return os.Remove(p)
if err := os.Remove(p); err != nil {
return err
}
// Le prompt système du preset vit en base, pas dans son .env (web_sysprompt.go) :
// sans ce ménage, il resterait orphelin et ressusciterait sur un preset recréé
// sous le même id.
_ = putStr(bkState, sysPromptKeyPrefix+id, "")
// Même ménage pour son benchmark : un preset recréé sous cet id en
// hériterait, alors qu'il a été mesuré sur un autre modèle.
deletePresetBench(id)
return nil
}
// ReadPreset returns the contents of a preset by id (filename).
@@ -0,0 +1,60 @@
package loki
import (
"os"
"path/filepath"
"testing"
)
// Ordre choisi par glisser-déposer : les presets classés d'abord, dans cet
// ordre ; les nouveaux (non classés) ensuite, par nom.
func TestPresetOrder(t *testing.T) {
testHome(t)
_ = os.MkdirAll(presetsDir(), 0o755)
for _, id := range []string{"alpha", "bravo", "charlie", "delta"} {
if err := os.WriteFile(filepath.Join(presetsDir(), id+".env"), []byte("# NAME="+id+"\nCTX=4096\n"), 0o644); err != nil {
t.Fatal(err)
}
}
ids := func() []string {
list, err := ListPresets()
if err != nil {
t.Fatal(err)
}
var out []string
for _, p := range list {
out = append(out, p.ID)
}
return out
}
if got := ids(); got[0] != "alpha" || got[3] != "delta" {
t.Fatalf("sans ordre enregistré, tri par nom attendu : %v", got)
}
if err := savePresetOrder([]string{"charlie", "alpha"}); err != nil {
t.Fatal(err)
}
want := []string{"charlie", "alpha", "bravo", "delta"}
got := ids()
for i := range want {
if got[i] != want[i] {
t.Fatalf("ordre %v, attendu %v", got, want)
}
}
}
func TestPresetCtx(t *testing.T) {
cases := map[string]string{
"": "32K", // défaut de serve : 32768
"CTX=40000\n": "40K",
"CTX=\"131072\"\n": "128K",
"CTX=1000000\n": "1M",
"CTX=1500000\n": "1.5M",
"CTX=512\n": "512",
"CTX=abc\n": "",
}
for in, want := range cases {
if got := presetCtx(in); got != want {
t.Errorf("presetCtx(%q) = %q, attendu %q", in, got, want)
}
}
}
+111 -2
View File
@@ -47,7 +47,7 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
}
}
// Configuration courante : un preset + les réglages machine.
setConfig(t, "MODEL=ancien.gguf\nCTX=4096\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\nMEM_MODE=always\n")
setConfig(t, "MODEL=ancien.gguf\nCTX=4096\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\nMEM_MODE=always\nCUDA_LAUNCH_QUEUES=off\n")
target := filepath.Join(home, "cible.env")
write(target, "MODEL=nouveau.gguf\nCTX=8192\n")
@@ -60,7 +60,7 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
t.Fatalf("le preset n'a pas été appliqué : %v", cfg)
}
for k, want := range map[string]string{
"CUDA_VISIBLE_DEVICES": "1", "WEB_ENGINE": "go", "MEM_MODE": "always",
"CUDA_VISIBLE_DEVICES": "1", "WEB_ENGINE": "go", "MEM_MODE": "always", "CUDA_LAUNCH_QUEUES": "off",
} {
if cfg[k] != want {
t.Errorf("%s = %q après bascule, attendu %q (réglage machine effacé)", k, cfg[k], want)
@@ -68,6 +68,64 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
}
}
// Le moteur suit la machine. Vécu : moteur mis à jour depuis l'interface
// (BIN → /data/engine/server-cuda-b10680), Qwen3.8-Flash-Next charge ; on
// change de modèle — le preset, créé avant, porte BIN=/app/llama-server — et
// le vieux moteur revient : « unknown model architecture: 'qwen4exp' ». Seul
// un backend personnalisé, choix par modèle, a le droit d'imposer son BIN.
func TestSwitchToPresetGardeLeMoteur(t *testing.T) {
home := testHome(t)
t.Setenv("LOKI_ENGINE_BIN", "/app/llama-server")
write := func(p, body string) {
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
engine := filepath.Join(home, "engine", "server-cuda-b10680", "llama-server")
setConfig(t, "MODEL=ancien.gguf\nBIN="+engine+"\n")
// Preset d'avant la mise à jour : il porte le moteur de l'image.
image := filepath.Join(home, "image.env")
write(image, "MODEL=nouveau.gguf\nBIN=/app/llama-server\n")
if err := applyPresetFile(image); err != nil {
t.Fatal(err)
}
if got := ReadConfig()["BIN"]; got != engine {
t.Errorf("BIN = %q après bascule, attendu le moteur mis à jour %q", got, engine)
}
// Preset muet sur le moteur : idem.
muet := filepath.Join(home, "muet.env")
write(muet, "MODEL=autre.gguf\n")
if err := applyPresetFile(muet); err != nil {
t.Fatal(err)
}
if got := ReadConfig()["BIN"]; got != engine {
t.Errorf("BIN = %q après preset muet, attendu %q conservé", got, engine)
}
// Backend personnalisé : le preset gagne.
custom := filepath.Join(home, "custom.env")
write(custom, "MODEL=exotique.gguf\nBIN=/opt/mon-fork/llama-server\n")
if err := applyPresetFile(custom); err != nil {
t.Fatal(err)
}
if got := ReadConfig()["BIN"]; got != "/opt/mon-fork/llama-server" {
t.Errorf("BIN = %q, attendu le backend personnalisé du preset", got)
}
// Un nouveau preset ne fige pas ce backend-là… si, justement : c'est un
// choix par modèle. Mais il ne fige ni l'image ni un moteur téléchargé.
if seed := newPresetSeed(); seed["BIN"] != "/opt/mon-fork/llama-server" {
t.Errorf("seed BIN = %q, attendu le backend personnalisé", seed["BIN"])
}
setConfig(t, "MODEL=ancien.gguf\nBIN="+engine+"\n")
if seed := newPresetSeed(); seed["BIN"] != "" {
t.Errorf("seed BIN = %q, un moteur téléchargé ne doit pas être figé dans un preset", seed["BIN"])
}
setConfig(t, "MODEL=ancien.gguf\nBIN=/app/llama-server\n")
if seed := newPresetSeed(); seed["BIN"] != "" {
t.Errorf("seed BIN = %q, le moteur de l'image ne doit pas être figé dans un preset", seed["BIN"])
}
}
// Un preset qui définit LUI-MÊME la sélection de cartes doit gagner sur celle
// de la machine : « FABLE 2 GPU » impose CUDA_VISIBLE_DEVICES=1,0 pour que son
// --tensor-split ait deux cartes. Écraser ça par une sélection mono-GPU faisait
@@ -102,3 +160,54 @@ func TestSwitchToPresetLaisseLePresetImposerSesGPU(t *testing.T) {
t.Errorf("WEB_ENGINE = %q, attendu go (réglage machine, jamais dicté par un preset)", cfg["WEB_ENGINE"])
}
}
// Modifier le preset ACTIF doit changer la configuration courante. Vécu : CTX
// passé à 100000 dans l'éditeur, « enregistré » — et la carte du chat qui
// affiche toujours 32768, parce que seul le fichier du preset avait bougé ;
// config.env, lui, gardait l'ancienne valeur et plus aucun preset n'était
// détecté actif. Un preset qui n'est pas celui en service, ou un preset
// nouveau, ne touche pas à la configuration.
func TestModifierLePresetActifLApplique(t *testing.T) {
testHome(t)
dir := presetsDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
write := func(name, body string) {
if err := os.WriteFile(filepath.Join(dir, name+".env"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("actif", "# NAME=Actif\nMODEL=foo.gguf\nCTX=32768\n")
write("autre", "# NAME=Autre\nMODEL=bar.gguf\nCTX=4096\n")
setConfig(t, "MODEL=foo.gguf\nCTX=32768\nMEM_MODE=always\n")
// Le preset en service : la config suit, les réglages machine restent.
id, applied, err := SavePresetApplying("actif", "Actif", "# NAME=Actif\nMODEL=foo.gguf\nCTX=100000\n")
if err != nil {
t.Fatal(err)
}
if id != "actif" || !applied {
t.Fatalf("id=%q applied=%v, attendu actif/true", id, applied)
}
cfg := ReadConfig()
if cfg["CTX"] != "100000" || cfg["MEM_MODE"] != "always" {
t.Fatalf("configuration non suivie : %v", cfg)
}
// Un autre preset : le fichier change, la config non.
if _, applied, err = SavePresetApplying("autre", "Autre", "# NAME=Autre\nMODEL=bar.gguf\nCTX=8192\n"); err != nil {
t.Fatal(err)
}
if applied || ReadConfig()["CTX"] != "100000" {
t.Fatalf("un preset inactif a touché la configuration (applied=%v, CTX=%q)", applied, ReadConfig()["CTX"])
}
// Un preset nouveau : jamais appliqué à la volée.
if _, applied, err = SavePresetApplying("", "Neuf", "MODEL=baz.gguf\n"); err != nil {
t.Fatal(err)
}
if applied || ReadConfig()["MODEL"] != "foo.gguf" {
t.Fatalf("un preset nouveau a touché la configuration (applied=%v)", applied)
}
}
+56
View File
@@ -0,0 +1,56 @@
package loki
import "testing"
// Le sampling du preset doit se retrouver dans le corps de la requête, avec les
// bons types (top_k entier, le reste flottant), et TEMP écrase la température
// déjà posée. Une clé vide n'ajoute rien : le serveur garde son défaut.
func TestApplySamplingFrom(t *testing.T) {
payload := map[string]any{"temperature": 0.7}
applySamplingFrom(payload, map[string]string{
"TEMP": "1.0",
"TOP_P": "0.95",
"TOP_K": "20",
"MIN_P": "0",
"REPEAT_PENALTY": "1.05",
"PRESENCE_PENALTY": "", // vide → ignoré
})
if payload["temperature"] != 1.0 {
t.Errorf("TEMP doit écraser la température : %v", payload["temperature"])
}
if payload["top_p"] != 0.95 || payload["min_p"] != 0.0 || payload["repeat_penalty"] != 1.05 {
t.Errorf("flottants attendus : %v %v %v", payload["top_p"], payload["min_p"], payload["repeat_penalty"])
}
if payload["top_k"] != 20 {
t.Errorf("top_k doit être un entier 20, pas %#v", payload["top_k"])
}
if _, ok := payload["presence_penalty"]; ok {
t.Error("une clé vide ne doit rien envoyer")
}
}
// L'échantillonnage ne touche PAS au raisonnement : ce chemin appartient à
// llm_client.go/llm_effort.go, qui y met aussi `enable_thinking`. L'amont écrit
// `chat_template_kwargs` ici ; le faire chez nous effacerait la consigne
// « aucune » — donc le modèle réfléchirait alors que l'interface dit l'inverse.
func TestApplySamplingNeTouchePasAuRaisonnement(t *testing.T) {
payload := map[string]any{"chat_template_kwargs": map[string]any{"enable_thinking": false}}
applySamplingFrom(payload, map[string]string{"REASONING_EFFORT": "medium", "TOP_K": "20"})
kw, ok := payload["chat_template_kwargs"].(map[string]any)
if !ok || kw["enable_thinking"] != false || len(kw) != 1 {
t.Errorf("chat_template_kwargs modifié : %v", payload["chat_template_kwargs"])
}
if _, ok := payload["reasoning_effort"]; ok {
t.Error("reasoning_effort ne doit pas être posé par l'échantillonnage")
}
}
// Sans aucune clé de sampling, le payload n'est pas modifié : on ne force pas de
// valeurs qui contrediraient le défaut du modèle.
func TestApplySamplingEmpty(t *testing.T) {
payload := map[string]any{"temperature": 0.7}
applySamplingFrom(payload, map[string]string{})
if len(payload) != 1 || payload["temperature"] != 0.7 {
t.Errorf("payload modifié à tort : %v", payload)
}
}
+730 -76
View File
@@ -4,14 +4,18 @@ import (
"bytes"
"context"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
// binSupportsReasoningFlag dit si ce llama-server accepte « --reasoning ».
// helpSupportsReasoningFlag dit si ce llama-server accepte « --reasoning ».
//
// Le drapeau est récent : les moteurs plus anciens, et certains forks, ne le
// connaissent pas et REFUSENT de démarrer sur un argument inconnu. Comme on ne
@@ -21,24 +25,267 @@ import (
// L'aide se lit avec le même chemin de bibliothèques que le vrai lancement
// (setLibraryPath a déjà été appelé) : sans ça un moteur parfaitement valide
// échoue à s'exécuter (« libllama-common.so introuvable ») et on conclurait à
// tort qu'il ne gère pas le drapeau.
func binSupportsReasoningFlag(bin string) bool {
// tort qu'il ne gère pas le drapeau. Les tests de capacité prennent le TEXTE de
// l'aide, pas le binaire : buildServeArgs reste ainsi une fonction pure,
// testable avec une aide fabriquée.
func helpSupportsReasoningFlag(help string) bool {
return strings.Contains(help, "--reasoning ")
}
// binHelp lit « <bin> --help » UNE fois par binaire et garde le texte. Plusieurs
// capacités se déduisent de cette aide (--reasoning, --load-mode, « -ngl auto »)
// et relancer le moteur à chaque question coûterait quelques secondes de plus au
// démarrage. Aide illisible = chaîne vide : tous les tests de capacité répondent
// « non », c'est-à-dire « ne prends aucun risque, garde la forme historique ».
var (
helpMu sync.Mutex
helpCache = map[string]string{}
)
func binHelp(bin string) string {
helpMu.Lock()
defer helpMu.Unlock()
if h, ok := helpCache[bin]; ok {
return h
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "--help")
var out bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &out
if err := cmd.Run(); err != nil && out.Len() == 0 {
return false // aide illisible : on ne prend pas le risque
h := ""
if err := cmd.Run(); err == nil || out.Len() > 0 {
h = out.String()
}
return strings.Contains(out.String(), "--reasoning ")
helpCache[bin] = h
return h
}
// helpSupportsLoadMode : les moteurs récents ont fusionné --mlock, --mmap et
// --no-mmap dans un seul --load-mode ; les anciens ne connaissent que les trois
// drapeaux d'origine. On traduit donc à l'exécution, sans réécrire le preset :
// le même EXTRA_ARGS reste lançable sur les deux générations de moteur.
func helpSupportsLoadMode(help string) bool {
return strings.Contains(help, "--load-mode")
}
// helpSupportsNGLAuto : « -ngl auto » laisse llama.cpp mesurer la VRAM libre et
// choisir le nombre de couches. Imposer un nombre (999 compris) désarme ce
// calcul — « n_gpu_layers already set by user to 999, abort » — et le moteur
// tente alors de tout mettre sur le GPU, quitte à échouer en cudaMalloc.
func helpSupportsNGLAuto(h string) bool {
i := strings.Index(h, "--n-gpu-layers")
if i < 0 {
i = strings.Index(h, "-ngl")
}
if i < 0 {
return false
}
end := i + 400
if end > len(h) {
end = len(h)
}
return strings.Contains(h[i:end], "'auto'")
}
// helpFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
//
// La lecture fine de l'aide (helpSupportsNGLAuto) reste la source de vérité, mais
// elle dépend de la mise en page d'un texte d'aide — une description reformulée
// ou une colonne plus large, et on conclut « non » sur un moteur parfaitement
// capable, donc on lui réimpose 999 et l'abandon revient. --load-mode est arrivé
// dans la même vague que « -ngl auto » : sa présence sert de second témoin, plus
// grossier mais insensible à la mise en page.
func helpFitsLayersItself(help string) bool {
return helpSupportsNGLAuto(help) || helpSupportsLoadMode(help)
}
// nglArgs traduit la valeur NGL du preset en arguments, et renvoie au passage la
// note à afficher quand Loki a substitué quelque chose. Fonction pure : la seule
// question qui demande le moteur (« sait-il répartir les couches tout seul ? »)
// arrive déjà tranchée, ce qui la rend testable sans lancer llama-server.
func nglArgs(ngl string, fitsItself bool) ([]string, string) {
ngl = strings.TrimSpace(ngl)
switch {
case strings.EqualFold(ngl, "auto"):
return nil, "" // rien du tout : le moteur décide seul
case ngl == "999" && fitsItself:
return []string{"-ngl", "auto"},
"NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la " +
"VRAM libre. NGL=all pour forcer l'ancien comportement."
case ngl != "":
return []string{"-ngl", ngl}, ""
case fitsItself:
return []string{"-ngl", "auto"}, ""
default:
// Moteur ancien : omettre -ngl le ferait tourner 100 % CPU.
return []string{"-ngl", "999"}, ""
}
}
// hasAnyFlag dit si l'utilisateur a déjà posé l'un de ces drapeaux dans
// EXTRA_ARGS. Loki ajoute plusieurs valeurs par défaut (--parallel, -ngl) : les
// ajouter EN PLUS de celles de l'utilisateur fait râler le moteur (« argument
// specified multiple times ») et, pire, c'est la DERNIÈRE occurrence qui gagne
// — le réglage explicite du preset serait donc silencieusement écrasé par le
// défaut de Loki. Quand l'utilisateur a tranché, on se tait.
func hasAnyFlag(args []string, flags ...string) bool {
for _, a := range args {
name := a
if i := strings.IndexByte(name, '='); i > 0 {
name = name[:i]
}
for _, f := range flags {
if name == f {
return true
}
}
}
return false
}
// normalizeLoadFlags choisit, au lancement, la forme des drapeaux de chargement
// que CE binaire comprend. llama.cpp récent a remplacé --mlock / --no-mmap par
// --load-mode (et refuse les anciens) ; un moteur ancien ou un fork ne connaît
// que les anciens. L'éditeur écrit --load-mode, un preset d'avant peut porter
// les anciens : on traduit dans le sens qu'il faut (repris d'AJEAN 0.16.0).
//
// Vers --load-mode (moteur récent). Deux interrupteurs → une seule valeur, au
// sens où llama.cpp l'entend (« mlock » = PAS de mmap + résident) :
//
// --mlock seul → mmap+mlock (l'ancien sens de --mlock : mmap conservé)
// --no-mmap seul → none
// --mlock + --no-mmap → mlock
// --mmap → mmap
//
// ⚠️ Jusqu'ici --mlock seul devenait « mlock », qui COUPE le mmap : le modèle
// entier montait en RAM au lieu d'être mappé — un OOM assuré sur un modèle plus
// gros que la mémoire. Un --load-mode déjà écrit gagne sur tout : on retire
// alors simplement les vieux drapeaux.
//
// Vers les anciens drapeaux (moteur qui ne connaît pas --load-mode) :
//
// none → --no-mmap · mlock → --mlock --no-mmap · mmap+mlock → --mlock
// auto, mmap, dio → rien (défaut de l'ancien moteur)
//
// La note éventuelle (dio abandonné) est RENDUE, pas affichée : buildServeArgs
// reste pure et c'est cmdServe qui l'écrit, à sa place parmi les autres notes.
func normalizeLoadFlags(args []string, supportsLoadMode bool) ([]string, string) {
if !supportsLoadMode {
return downgradeLoadMode(args)
}
explicit := hasAnyFlag(args, "--load-mode", "-lm")
mlock, nommap, mmap := false, false, false
out := make([]string, 0, len(args)+2)
for _, a := range args {
switch a {
case "--mlock":
mlock = true
case "--no-mmap":
nommap = true
case "--mmap":
mmap = true
default:
out = append(out, a)
}
}
if explicit || !(mlock || nommap || mmap) {
return out, ""
}
mode := "mmap"
switch {
case mlock && nommap:
mode = "mlock"
case mlock:
mode = "mmap+mlock"
case nommap:
mode = "none"
}
return append(out, "--load-mode", mode), ""
}
// downgradeLoadMode retraduit un --load-mode pour un moteur qui ne le connaît
// pas : le lui passer tel quel le ferait sortir en erreur au démarrage.
func downgradeLoadMode(args []string) ([]string, string) {
mode, found := "", false
kept := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--load-mode" || a == "-lm" {
found = true
if i+1 < len(args) {
mode = strings.ToLower(args[i+1])
i++
}
continue
}
if v, ok := strings.CutPrefix(a, "--load-mode="); ok {
found, mode = true, strings.ToLower(v)
continue
}
kept = append(kept, a)
}
if !found {
return args, ""
}
add := func(flag string) {
for _, a := range kept {
if a == flag {
return
}
}
kept = append(kept, flag)
}
switch mode {
case "none":
add("--no-mmap")
case "mlock":
add("--mlock")
add("--no-mmap")
case "mmap+mlock":
add("--mlock")
case "dio":
return kept, "ce moteur ne connaît pas --load-mode dio (DirectIO) : chargement par défaut"
}
return kept, ""
}
// cmdServe replaces the historic start.sh: read config.env, build the
// llama-server invocation, and exec it (replacing this process so systemd
// supervises llama-server directly).
//
// Tout ce qui touche au monde (fichiers, aide du moteur, variables
// d'environnement, port, exec) reste ICI ; la composition de la ligne de
// commande vit dans buildServeArgs, pure, pour qu'un changement de drapeau se
// vérifie en table de tests plutôt qu'en relançant un moteur.
func cmdServe(args []string) error {
cfg := ReadConfig()
// Moteur d'ESSAI de l'optimiseur (backend_tune_run.go) : sa configuration
// est une copie posée dans le dossier de l'essai, jamais config.env, et rien
// de ce que le vrai moteur laisse derrière lui (état des slots, jeton de
// SPEC=auto) n'est touché. Sans la variable, rien ne change.
trialDir := os.Getenv(tuneTrialEnv)
if trialDir != "" {
c, err := readTuneTrialConfig(trialDir)
if err != nil {
return err
}
cfg = c
}
// API externe : aucun moteur local. On sort SANS erreur, sinon systemd
// (Restart=on-failure) relance en boucle « MODEL non défini » (amont #95).
if isExternalConfig(cfg) {
fmt.Println("[info] preset externe actif : pas de moteur local à lancer")
return nil
}
// Preset ENGINE=strata (backend_strata.go) : un autre moteur, lancé par son
// propre serveur. L'optimiseur ne règle que llama-server.
if isStrataConfig(cfg) {
if trialDir != "" {
return fmt.Errorf("l'optimiseur ne s'applique pas à un preset Strata")
}
return serveStrata(cfg)
}
bin := cfg["BIN"]
if bin == "" {
return fmt.Errorf("BIN non défini — lance « loki edit »")
@@ -81,50 +328,12 @@ func cmdServe(args []string) error {
// Linux, PATH on Windows — handled inside execServer.
setLibraryPath(filepath.Dir(bin))
// Sélection GPU (loki gpu) : on filtre les devices visibles par llama-server.
// CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index correspondent à ceux
// affichés par nvidia-smi (sinon CUDA réordonne par "device le plus rapide").
if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" {
_ = os.Setenv("CUDA_VISIBLE_DEVICES", v)
_ = os.Setenv("CUDA_DEVICE_ORDER", "PCI_BUS_ID")
}
// La sélection GPU est posée AVANT de lire l'aide du moteur, comme elle l'a
// toujours été : le moteur interrogé voit déjà les mêmes devices que celui
// qu'on lancera. buildServeArgs la renvoie aussi dans env (même valeur).
applyServeEnv(cudaDeviceEnv(cfg))
get := func(key, fallback string) string {
if v, ok := cfg[key]; ok && v != "" {
return v
}
return fallback
}
kv := get("KV_TYPE", "")
ktv := get("KV_TYPE_K", kv)
vtv := get("KV_TYPE_V", kv)
llmArgs := []string{bin,
"-m", model,
"-c", get("CTX", "32768"),
"-t", get("THREADS", "0"),
"-tb", get("THREADS_BATCH", "0"),
"-b", get("BATCH", "2048"),
"-ub", get("UBATCH", "512"),
"--host", get("HOST", "0.0.0.0"),
"--port", get("PORT", "8080"),
}
// NGL=auto : on n'envoie PAS -ngl, et llama.cpp choisit lui-même combien de
// couches tiennent dans la VRAM libre (common_fit_params). Il s'en abstient
// dès que la valeur est imposée — « n_gpu_layers already set by user to 999,
// abort » — d'où des cudaMalloc en échec, un repli partiel sur le CPU et un
// débit effondré alors que le GPU tourne à 100 %.
// Le défaut reste 999 (tout sur le GPU) : omettre -ngl sur un llama.cpp
// antérieur à cet ajustement automatique le ferait tourner 100 % CPU.
if ngl := get("NGL", "999"); !strings.EqualFold(ngl, "auto") {
llmArgs = append(llmArgs, "-ngl", ngl)
}
if ktv != "" {
llmArgs = append(llmArgs, "-ctk", ktv)
}
if vtv != "" {
llmArgs = append(llmArgs, "-ctv", vtv)
}
si := serveSysInfo{Help: binHelp(bin), Model: model}
// Vision : le projecteur multimodal (mmproj-*.gguf) donne des yeux au modèle.
// C'est un fichier .gguf À PART du modèle, chargé via --mmproj. On le résout
// comme le modèle (nom simple cherché dans les dossiers déclarés, ou chemin
@@ -140,7 +349,367 @@ func cmdServe(args []string) error {
if _, err := os.Stat(mmPath); err != nil {
return fmt.Errorf("projecteur vision introuvable : %s", mmPath)
}
llmArgs = append(llmArgs, "--mmproj", mmPath)
si.MMProj = mmPath
}
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
si.APIKey, _ = effectiveAPIKeyErr()
// Conteneur à l'étroit (cpuset, quota) : seul Linux l'expose, dans /proc et
// /sys. Un LLAMA_ARG_THREADS déjà posé est un choix de l'utilisateur — un -t
// de Loki l'écraserait, donc on ne sonde même pas.
if runtime.GOOS == "linux" && os.Getenv("LLAMA_ARG_THREADS") == "" {
si.CPU = cpusetThreads(os.DirFS("/"))
}
extra := splitArgs(cfg["EXTRA_ARGS"])
// Deux GPU ou plus : de quoi décider des files de lancement CUDA (voir
// launchQueuesEnv). Après la sélection GPU, qui fait partie de la réponse.
probeServeGPUs(cfg, extra, &si)
probeExpertEnv(&si)
probeLoadEnv(&si)
probeFidelityEnv(&si)
probeCacheRAM(cfg, extra, &si)
probeCkptEnv(&si)
probeSideSlotEnv(cfg, &si)
// SPLIT_MODE=tensor seulement : cartes listées par CE moteur, dans son ordre.
probeSplit(cfg, bin, &si)
// LOAD_GUARD : un refus envisagé sur la VRAM NVIDIA se décide sur les
// cartes du moteur (Vulkan sur cartes mixtes, ROCm…). Sinon rien n'est lu.
normExtra, _ := normalizeLoadFlags(extra, helpSupportsLoadMode(si.Help))
probeLoadDevices(cfg, normExtra, bin, &si)
spec := specMode(cfg)
if spec != "off" && spec != "" {
probeSpec(cfg, &si)
}
// Build du moteur : seulement pour un hybride ou SPEC=auto, les seuls cas où
// il décide de quelque chose (voir ckptArgs, specAutoBlocker) — inutile de
// lancer « --version » sinon.
if ggufHybrid(si.GGUF) || spec == "auto" {
si.EngineBuild = engineBuildTrusted(bin)
}
// Lu seulement ici ; rien n'est consommé avant que le port soit libre.
specMark := specAutoMark{FP: configFingerprint(cfg), Bin: bin, Build: si.EngineBuild}
specRecord := false
if spec == "auto" {
si.SpecAutoBlocked, specRecord = specAutoPeek(specMark)
}
probeSlotPersistEnv(cfg, &si)
if strings.Contains(si.Help, "--slot-save-path") && !hasAnyFlag(extra, "--slot-save-path") {
if trialDir != "" {
// L'essai a son propre dossier : celui du vrai moteur garde ses états.
si.SlotDir = prepareSlotDir(trialDir, false)
} else {
si.SlotDir = prepareSlotDir(LokiHome(), slotPersistOn(cfg))
}
}
llmArgs, env, notes := buildServeArgs(cfg, extra, bin, si)
applyServeEnv(env)
// SLOT_PERSIST (backend_serve_persist.go) : la clé de CE lancement, et les
// états gardés au-delà de deux retirés. Refusé pour ce preset (brouillon,
// poids sur CPU…) : ceux des autres presets restent, pour leur retour.
// Clé retirée : plus aucun état gardé sur le disque.
persistKey := ""
persistOK, _ := slotPersistPlan(cfg, extra, si)
switch {
case trialDir != "":
// Un essai ne garde rien et n'efface rien.
case persistOK:
persistKey = slotPersistKey(llmArgs, env, si, bin)
slotPersistPrune(si.SlotDir)
case !slotPersistOn(cfg):
slotPersistPurge(filepath.Join(LokiHome(), "slots"))
}
for _, n := range notes {
fmt.Fprintln(os.Stderr, "[loki serve] "+n)
}
// Chargement résident avec plus de poids en RAM que la machine n'en a :
// échec certain (voir loadModeRisk), LOAD_GUARD=off pour passer outre.
if _, refuse := loadModeRisk(cfg, llmArgs, si); refuse != "" {
return loadGuardRefusal(refuse, trialDir != "")
}
// Essai : écoute sur 127.0.0.1 et le port libre choisi par l'optimiseur,
// même si EXTRA_ARGS en nomme d'autres (le dernier --port gagne). La ligne
// composée est rendue à l'optimiseur, qui la compare à celle de référence
// AVANT de charger quoi que ce soit ; à blanc, on s'arrête là.
if trialDir != "" {
llmArgs = tuneForceAddr(llmArgs, cfg["HOST"], cfg["PORT"])
l := tuneLaunch{Bin: bin, Args: llmArgs, Env: env, Notes: notes, Probe: tuneProbeFrom(si)}
if err := writeTuneLaunch(trialDir, l); err != nil {
return err
}
if os.Getenv(tuneDryRunEnv) != "" {
return nil
}
}
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
// mmproj-F16.gguf) still resolve.
_ = os.Chdir(LokiHome())
// Port déjà pris (souvent un llama-server orphelin qu'un stop n'a pas pu
// tuer) : deux moteurs sur un même port se partagent les requêtes au hasard,
// VRAM saturée et réponses du mauvais modèle. On refuse en clair.
host, port := argValue(llmArgs, "--host"), argValue(llmArgs, "--port")
if err := waitPortFree(host, port, 5*time.Second); err != nil {
return err
}
// Le type EFFECTIF : un -ctk/-ctv d'EXTRA_ARGS l'emporte sur KV_TYPE, et c'est
// lui que le moteur accélère ou non.
kt, vt, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
warnSlowKV(kt, vt)
// Jeton de tentative : l'ancien consommé (et l'échec inscrit), le nouveau
// posé, au dernier moment — port libre, l'ancien moteur est donc bien parti
// et le process web ne peut pas le confondre avec lui.
if spec == "auto" && trialDir == "" {
_, _, auto := specArgs(cfg, extra, si)
specAutoSettle(specMark, si.SpecAutoBlocked, specRecord, auto)
}
// Posée au dernier moment, port libre : l'ancien moteur est parti, la clé
// lue par le process web est celle du moteur qui répondra.
if trialDir == "" {
writeSlotPersistMarker(LokiHome(), persistKey)
}
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
bin, filepath.Base(model), port)
// Ligne de commande réellement lancée, gardée pour l'interface (amont #108).
if trialDir == "" {
recordEngineCmdline(env, llmArgs)
}
// Hand off to the llama-server process. On Unix this replaces the current
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
return execServer(bin, llmArgs)
}
// serveSysInfo rassemble ce que cmdServe a dû demander au monde avant de
// composer la ligne de commande : l'aide du moteur (d'où se déduisent ses
// capacités), les chemins déjà résolus et vérifiés, la clé d'API. Avec elle,
// buildServeArgs n'a plus besoin de toucher ni au disque ni au binaire.
type serveSysInfo struct {
Help string // sortie de « <bin> --help » ; vide = moteur inconnu, aucun risque pris
Model string // chemin du .gguf principal, résolu et vérifié
MMProj string // chemin du projecteur vision, résolu et vérifié ; vide = pas de vision
APIKey string // clé effective (.api_key, sinon config.env) ; vide = serveur ouvert
CPU cpuBudget // sonde de conteneur (Linux) ; zéro = llama.cpp choisit ses threads seul
LaunchQueues string // CUDA_SCALE_LAUNCH_QUEUES déjà dans l'environnement : choix de l'utilisateur, intouché
GPUs int // GPU visibles (CUDA_VISIBLE_DEVICES, sinon nvidia-smi) ; 0 = inconnu ou non sondé
ArgEnv map[string]string // LLAMA_ARG_* de l'environnement qui décident du pipeline, de --fit et du cache (serveArgEnv, fitArgEnv, fidelityArgEnv)
UserEnv map[string]string // GGML_* des réglages d'expert déjà posés (voir expertEnvKeys) : intouchés
// Cache de prompts (voir backend_serve_cache.go). Zéro = inconnu : le moteur
// garde son défaut.
GGUF *GGUFInfo // métadonnées du modèle ; nil = illisibles
ModelBytes int64 // taille du modèle, toutes tranches
RAMMiB int64 // RAM de la machine, limite du conteneur comprise
RAMAvailMiB int64 // RAM libre au lancement
VRAMMiB int64 // VRAM NVIDIA des cartes visibles
UnifiedMem bool // macOS : RAM et VRAM ne font qu'une (voir loadModeRisk)
// VRAM des cartes listées par le moteur lui-même (--list-devices), lue
// seulement quand LOAD_GUARD envisage un refus (probeLoadDevices) ; 0 =
// non lue : un avis au plus, jamais de refus sur la seule VRAM NVIDIA.
EngineVRAMMiB int64
SlotDir string // dossier de --slot-save-path, créé et vérifié ; vide = pas de drapeau
// Build d'un moteur officiel (engineBuildTrusted), lu seulement pour un
// modèle hybride ou SPEC=auto ; 0 = inconnu ou non lu : ni avis ni drapeau
// automatique.
EngineBuild int
// Décodage spéculatif (voir backend_serve_spec.go), lu seulement si SPEC le
// demande.
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles
DraftIsModel bool // MODEL_DRAFT désigne le modèle principal lui-même (voir specArgs)
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
// Cartes listées par le moteur (--list-devices), lues seulement pour
// SPLIT_MODE=tensor (voir backend_serve_split.go) ; vide = inconnues.
SplitDevs []splitDev
}
// cudaDeviceEnv : sélection GPU (loki gpu), on filtre les devices visibles par
// llama-server. CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index
// correspondent à ceux affichés par nvidia-smi (sinon CUDA réordonne par
// « device le plus rapide »).
func cudaDeviceEnv(cfg map[string]string) map[string]string {
env := map[string]string{}
if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" {
env["CUDA_VISIBLE_DEVICES"] = v
env["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID"
}
return env
}
func applyServeEnv(env map[string]string) {
for k, v := range env {
_ = os.Setenv(k, v)
}
}
// serveKVTypes donne les types de cache K et V du preset : KV_TYPE pour les
// deux, KV_TYPE_K / KV_TYPE_V pour les régler séparément. Vide = défaut moteur.
func serveKVTypes(cfg map[string]string) (k, v string) {
kv := cfg["KV_TYPE"]
k, v = kv, kv
if s := cfg["KV_TYPE_K"]; s != "" {
k = s
}
if s := cfg["KV_TYPE_V"]; s != "" {
v = s
}
return k, v
}
// buildServeArgs compose la ligne de commande de llama-server (bin en tête) à
// partir du preset, d'EXTRA_ARGS tel que découpé par splitArgs et de ce que
// cmdServe a sondé. Fonction pure : elle renvoie aussi les variables
// d'environnement à poser et les notes à afficher, sans rien faire elle-même.
// L'ordre des arguments est celui qu'a toujours produit cmdServe — le moteur
// retient la DERNIÈRE occurrence d'un drapeau, donc l'ordre fait partie du
// comportement.
func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveSysInfo) (args []string, env map[string]string, notes []string) {
get := func(key, fallback string) string {
if v, ok := cfg[key]; ok && v != "" {
return v
}
return fallback
}
ktv, vtv := serveKVTypes(cfg)
env = cudaDeviceEnv(cfg)
// EXTRA_ARGS est lu ICI, avant de composer la ligne de commande : ce que
// l'utilisateur a écrit à la main décide des défauts que Loki a le droit
// d'ajouter (voir hasAnyFlag). Il est aussi traduit vers les drapeaux de
// chargement actuels quand le moteur les attend (voir normalizeLoadFlags).
extra, loadNote := normalizeLoadFlags(extra, helpSupportsLoadMode(si.Help))
if loadNote != "" {
notes = append(notes, loadNote)
}
// Parallélisme de tenseurs (SPLIT_MODE=tensor, voir backend_serve_split.go) :
// sans la clé, splitOn est faux et rien de ce qui suit ne change. Accepté, il
// compte pour les réglages qui lisent le placement dans EXTRA_ARGS (files de
// lancement, graphes CUDA, --fit) comme un -sm tensor écrit à la main.
splitOn, splitA, splitNGLArgs, splitEnv, splitNotes := splitTensorArgs(cfg, extra, si)
place := extra
if splitOn {
place = append(append([]string{}, extra...), "-sm", "tensor")
}
for k, v := range splitEnv {
env[k] = v
}
notes = append(notes, splitNotes...)
// Files de lancement CUDA : une variable d'environnement, pas un drapeau —
// la ligne de commande n'en dépend pas (voir launchQueuesEnv).
q, qNotes := launchQueuesEnv(cfg, place, si)
if q != "" {
env["CUDA_SCALE_LAUNCH_QUEUES"] = q
}
notes = append(notes, qNotes...)
// Réglages d'expert passés par variable (voir backend_serve_expert.go) :
// absents du preset, ils ne posent rien.
g, gNotes := graphOptEnv(cfg, place, si)
if g != "" {
env["GGML_CUDA_GRAPH_OPT"] = g
}
o, oNotes := opOffloadMinBatchEnv(cfg, si)
if o != "" {
env["GGML_OP_OFFLOAD_MIN_BATCH"] = o
}
notes = append(append(notes, gNotes...), oNotes...)
// Threads : vide ou 0 = AUCUN drapeau, pour que llama.cpp prenne ses cœurs
// physiques au lieu de tous les threads logiques (voir threadArgs).
threads, threadNotes := threadArgs(cfg["THREADS"], cfg["THREADS_BATCH"], extra, si.CPU)
notes = append(notes, threadNotes...)
// Second slot (SIDE_SLOT, voir backend_serve_side.go) : sans la clé, sideCtx
// vaut 0 et rien de ce qui suit ne change.
sideCtx, sideMiB, sideWhy := sideSlotPlan(cfg, extra, si)
ctxArg := get("CTX", "32768")
if sideCtx > 0 {
ctxArg = strconv.Itoa(2 * sideCtx)
}
args = []string{bin,
"-m", si.Model,
"-c", ctxArg,
}
args = append(args, threads...)
args = append(args,
"-b", get("BATCH", "2048"),
"-ub", get("UBATCH", "512"),
"--host", get("HOST", "0.0.0.0"),
"--port", get("PORT", "8080"),
)
// --parallel 1 EXPLICITE : les llama-server récents ouvrent plusieurs slots
// par défaut, soit des tampons de calcul GPU multipliés d'autant — pour un
// serveur mono-utilisateur comme Loki, c'est de la VRAM brûlée pour rien.
// Vécu : une config 27B/60k ctx qui tournait très bien est morte en
// « cudaMalloc failed: out of memory » après une mise à jour du moteur,
// uniquement à cause de ce nouveau défaut. PARALLEL=n dans le preset pour qui
// veut vraiment servir plusieurs requêtes à la fois — ou --parallel dans
// EXTRA_ARGS, auquel cas on ne redouble pas le drapeau.
if sideCtx > 0 {
// Deux flux KV séparés de sideCtx jetons : aucun slot ne peut manger la
// place de l'autre (voir backend_serve_side.go).
args = append(args, "--parallel", "2", "--no-kv-unified")
} else if !hasAnyFlag(extra, "--parallel", "-np") {
args = append(args, "--parallel", get("PARALLEL", "1"))
}
if n := sideSlotNote(sideCtx, sideMiB, sideWhy, nglForced(cfg, extra)); n != "" {
notes = append(notes, n)
}
// Couches GPU. Quatre cas, dans cet ordre :
//
// NGL=auto → rien du tout (le moteur décide seul)
// NGL=999 → -ngl auto sur un moteur récent (voir plus bas)
// NGL=<nombre> → -ngl <nombre> (l'utilisateur tranche pour de vrai)
// clé absente → -ngl auto si le moteur le propose, sinon -ngl 999
//
// Imposer un NOMBRE désarme common_fit_params, qui mesure la VRAM libre et
// choisit combien de couches y tiennent : « n_gpu_layers already set by user
// to 999, abort ». Le moteur pousse alors tout sur le GPU, échoue en
// cudaMalloc ou se replie à moitié sur le CPU — débit effondré, GPU à 100 %.
//
// 999 est traité à part parce que ce n'a JAMAIS été un nombre de couches :
// c'est la sentinelle historique « toutes », semée dans config.env par
// defaultConfig() sur chaque installation neuve. La quasi-totalité des
// configurations la portent donc sans que personne ne l'ait choisie — la
// traiter comme un choix délibéré, c'est condamner tout le monde à l'abandon
// ci-dessus. Sur un moteur qui connaît « auto », 999 devient donc auto, et on
// le DIT sur stderr plutôt que de le faire en douce. Qui veut réellement
// forcer tout sur le GPU écrit NGL=all (ou un nombre qui n'est pas 999).
if splitOn {
args = append(args, splitNGLArgs...) // « -ngl all », ou rien si EXTRA_ARGS l'a déjà
} else if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
ngl, note := nglArgs(get("NGL", ""), helpFitsLayersItself(si.Help))
if note != "" {
notes = append(notes, note)
}
args = append(args, ngl...)
}
// Marge VRAM par carte du placement automatique (FIT_TARGET, voir
// fitTargetArgs) : seulement si --fit tournera vraiment.
fitt, fitNotes := fitTargetArgs(cfg, place, si)
args = append(append(args, fitt...), splitA...)
notes = append(notes, fitNotes...)
if ktv != "" {
args = append(args, "-ctk", ktv)
}
if vtv != "" {
args = append(args, "-ctv", vtv)
}
// Vision : chemin déjà résolu et vérifié par cmdServe (voir là-bas).
if si.MMProj != "" {
args = append(args, "--mmproj", si.MMProj)
}
// Raisonnement. Trois cas, et la nuance compte :
//
@@ -160,39 +729,124 @@ func cmdServe(args []string) error {
// contenu). L'anti-boucle côté llm_client.go reste le garde-fou. NE PAS forcer 0 :
// sur llama.cpp vanilla, 0 = "immediate end" → coupe tout le raisonnement
// (le fork ik_llama.cpp l'ignore). Configurable via REASONING_BUDGET.
llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1"))
} else if binSupportsReasoningFlag(bin) {
args = append(args, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1"))
} else if helpSupportsReasoningFlag(si.Help) {
// Pas de budget ici : « off » suffit, et un budget sur un moteur qui
// n'attend rien d'autre ne ferait qu'ajouter une occasion d'échouer.
llmArgs = append(llmArgs, "--reasoning", "off")
args = append(args, "--reasoning", "off")
} else {
// Vieux moteur (ou fork) qui ne connaît pas le drapeau : le lui passer
// le ferait sortir en erreur au démarrage, donc boucler. On le dit et on
// continue sans — mieux vaut un modèle qui réfléchit qu'un moteur mort.
fmt.Fprintf(os.Stderr, "[loki serve] ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement\n")
notes = append(notes, "ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement")
}
}
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
if k := readAPIKey(); k != "" {
llmArgs = append(llmArgs, "--api-key", k)
} else if k := cfg["API_KEY"]; k != "" {
llmArgs = append(llmArgs, "--api-key", k)
// Conservation du raisonnement des tours passés : seulement si
// REASONING_PRESERVE est posée (voir reasoningPreserveArgs pour le compromis).
rp, rpNotes := reasoningPreserveArgs(cfg, extra, si)
args = append(args, rp...)
notes = append(notes, rpNotes...)
if si.APIKey != "" {
args = append(args, "--api-key", si.APIKey)
}
// Cache de prompts en RAM et dossier des slots (voir backend_serve_cache.go) :
// avant EXTRA_ARGS, qui garde le dernier mot.
cram, cramNotes := cacheRAMArgs(cfg, extra, si)
args = append(append(args, cram...), slotSaveArgs(cfg, extra, si)...)
notes = append(notes, cramNotes...)
if n := slotPersistNote(slotPersistPlan(cfg, extra, si)); n != "" {
notes = append(notes, n)
}
// Points de reprise des hybrides (voir backend_serve_ckpt.go).
ck, ckNotes := ckptArgs(cfg, extra, si)
args = append(args, ck...)
notes = append(notes, ckNotes...)
// Décodage spéculatif (voir backend_serve_spec.go) : rien tant que SPEC
// n'est pas posé.
sp, spNotes, _ := specArgs(cfg, extra, si)
args = append(args, sp...)
notes = append(notes, spNotes...)
// EXTRA_ARGS (déjà découpé comme le ferait le shell — les guillemets gardent
// ensemble un chemin qui contient des espaces) ferme la marche.
args = append(args, extra...)
// Garde-fous de fidélité (voir backend_serve_fidelity.go) : ils ne touchent à
// aucun drapeau, ils DISENT ce que la ligne finale change aux calculs.
ek, ev, src := effectiveKVTypes(cfg, extra, si.ArgEnv)
if n := kvFidelityNote(ek, ev, src, extra, si.ArgEnv); n != "" {
notes = append(notes, n)
}
notes = append(notes, lossyCacheNotes(extra, si)...)
// Experts MoE et mode de chargement (voir backend_serve_moe.go) : des notes
// seulement, la ligne ne bouge pas. Le refus d'un chargement voué à l'échec
// se décide dans cmdServe, sur cette même ligne.
notes = append(notes, moeNotes(cfg, extra, si)...)
if w, _ := loadModeRisk(cfg, args, si); w != "" {
notes = append(notes, w)
}
return args, env, notes
}
// argValue renvoie la valeur de la DERNIÈRE occurrence de flag (llama-server
// retient la dernière : EXTRA_ARGS peut surcharger --port).
func argValue(args []string, flag string) string {
v := ""
for i := 0; i+1 < len(args); i++ {
if args[i] == flag {
v = args[i+1]
}
}
return v
}
// waitPortFree vérifie que personne n'écoute déjà sur host:port. On tente une
// connexion TCP (fiable même si l'occupant a bindé en SO_REUSEADDR, où un
// Listen de test pourrait réussir). Un moteur qu'on vient d'arrêter peut tenir
// le port quelques instants : on réessaie jusqu'à wait avant de conclure.
// Repris d'AJEAN 0.15.9.
func waitPortFree(host, port string, wait time.Duration) error {
if port == "" {
return nil
}
h := strings.Trim(host, "[]")
if h == "" || h == "0.0.0.0" || h == "::" {
h = "127.0.0.1"
}
addr := net.JoinHostPort(h, port)
deadline := time.Now().Add(wait)
for {
c, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
if err != nil {
return nil
}
c.Close()
if time.Now().After(deadline) {
return fmt.Errorf("le port %s est déjà utilisé (sans doute un ancien llama-server encore actif) : "+
"« loki stop », docker restart loki, ou change PORT", port)
}
time.Sleep(500 * time.Millisecond)
}
}
// fastKV : combinaisons K/V que llama.cpp CUDA accélère en Flash-Attention
// avec ses options de compilation par défaut (GGML_CUDA_FA_ALL_QUANTS=OFF) —
// c'est le cas du moteur de l'image server-cuda comme de ceux tirés par OCI.
// Les autres (q5_*, q4_1, combinaisons mixtes) marchent mais sont reconverties
// en f16 à chaque pas : la génération ralentit nettement (AJEAN 0.15.9).
var fastKV = map[string]bool{"f16|f16": true, "q8_0|q8_0": true, "q4_0|q4_0": true, "bf16|bf16": true}
func slowKV(k, v string) bool {
if k == "" {
k = "f16"
}
if v == "" {
v = "f16"
}
return !fastKV[k+"|"+v]
}
func warnSlowKV(k, v string) {
if slowKV(k, v) {
fmt.Fprintf(os.Stderr, "[loki serve] avertissement : cache KV %s/%s non accéléré par llama.cpp CUDA "+
"(converti en f16 à chaque pas, lent). Préfère q8_0/q8_0, q4_0/q4_0 ou f16.\n", k, v)
}
// EXTRA_ARGS is appended verbatim, split like the shell would — quotes kept
// together so a path with spaces stays one argument.
llmArgs = append(llmArgs, splitArgs(cfg["EXTRA_ARGS"])...)
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
// mmproj-F16.gguf) still resolve.
_ = os.Chdir(LokiHome())
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
bin, filepath.Base(model), get("PORT", "8080"))
// Hand off to the llama-server process. On Unix this replaces the current
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
return execServer(bin, llmArgs)
}
+299
View File
@@ -0,0 +1,299 @@
package loki
import (
"reflect"
"testing"
)
// Deux aides de moteur fabriquées, réduites aux lignes que lisent les tests de
// capacité : un llama.cpp récent (--load-mode, « -ngl auto », --reasoning) et
// un ancien qui ne connaît que les drapeaux d'origine.
const (
helpRecent = `-ngl, --gpu-layers, --n-gpu-layers N max. number of layers to store in VRAM, either an exact number,
'auto', or 'all' (default: auto)
-lm, --load-mode MODE how to load the model: auto, mmap, mmap+mlock, mlock, none, dio
-rea, --reasoning [on|off|auto] use reasoning/thinking in the chat (default: auto)
`
helpOld = `-ngl, --gpu-layers, --n-gpu-layers N number of layers to store in VRAM
--mlock force system to keep model in RAM
--no-mmap do not memory-map model
`
)
// TestBuildServeArgs fige la ligne de commande produite pour les presets
// courants : la moindre différence d'ordre ou de valeur casse le test. Le
// moteur retient la DERNIÈRE occurrence d'un drapeau, donc l'ordre compte
// autant que les valeurs — c'est la base sur laquelle les réglages suivants
// viendront s'ajouter, chacun avec sa ligne ici.
func TestBuildServeArgs(t *testing.T) {
const bin, model = "/opt/llama/llama-server", "/models/Qwen3.6-27B-Q4_K_M.gguf"
base := []string{bin, "-m", model,
"-c", "32768", "-b", "2048", "-ub", "512",
"--host", "0.0.0.0", "--port", "8080"}
with := func(tail ...string) []string {
return append(append([]string{}, base...), tail...)
}
// withThreads insère -t / -tb à leur place, juste après -c.
withThreads := func(threads []string, tail ...string) []string {
out := append(append([]string{}, base[:5]...), threads...)
return append(append(out, base[5:]...), tail...)
}
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want []string
wantEnv map[string]string
wantNotes int
}{
{
name: "dense par défaut, moteur récent : 999 devient auto, et on le dit",
cfg: map[string]string{"NGL": "999"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "auto"), wantNotes: 1,
},
{
name: "dense par défaut, moteur ancien : 999 reste 999",
cfg: map[string]string{"NGL": "999"},
si: serveSysInfo{Help: helpOld},
want: with("--parallel", "1", "-ngl", "999"),
},
{
name: "clé NGL absente, aide illisible : forme historique",
cfg: map[string]string{},
si: serveSysInfo{},
want: with("--parallel", "1", "-ngl", "999"),
},
{
name: "NGL forcé à un nombre : jamais touché",
cfg: map[string]string{"NGL": "28"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28"),
},
{
name: "NGL=auto : aucun drapeau",
cfg: map[string]string{"NGL": "auto"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1"),
},
{
name: "réglages du preset et cache KV commun",
cfg: map[string]string{"CTX": "65536", "THREADS": "8", "THREADS_BATCH": "16", "BATCH": "4096",
"UBATCH": "1024", "HOST": "127.0.0.1", "PORT": "9090", "PARALLEL": "2", "NGL": "all",
"KV_TYPE": "q8_0"},
si: serveSysInfo{Help: helpRecent},
want: []string{bin, "-m", model,
"-c", "65536", "-t", "8", "-tb", "16", "-b", "4096", "-ub", "1024",
"--host", "127.0.0.1", "--port", "9090",
"--parallel", "2", "-ngl", "all", "-ctk", "q8_0", "-ctv", "q8_0"},
wantNotes: 1, // cache quantifié : dit, jamais retiré
},
{
name: "THREADS=0 et THREADS_BATCH=0 : aucun drapeau, llama.cpp prend ses cœurs physiques",
cfg: map[string]string{"NGL": "28", "THREADS": "0", "THREADS_BATCH": "0"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28"),
},
{
name: "THREADS=6 seul : -t 6, -tb laissé au moteur (il recopie -t)",
cfg: map[string]string{"NGL": "28", "THREADS": "6"},
si: serveSysInfo{Help: helpRecent},
want: withThreads([]string{"-t", "6"}, "--parallel", "1", "-ngl", "28"),
},
{
name: "THREADS_BATCH seul : -tb sans -t",
cfg: map[string]string{"NGL": "28", "THREADS_BATCH": "12"},
si: serveSysInfo{Help: helpRecent},
want: withThreads([]string{"-tb", "12"}, "--parallel", "1", "-ngl", "28"),
},
{
name: "THREADS illisible ou négatif : ignoré, et dit",
cfg: map[string]string{"NGL": "28", "THREADS": "auto", "THREADS_BATCH": "-1"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28"), wantNotes: 2,
},
{
name: "-t=6 dans EXTRA_ARGS : pas de -t de Loki",
cfg: map[string]string{"NGL": "28", "THREADS": "8", "THREADS_BATCH": "16", "EXTRA_ARGS": "-t=6 --threads-batch 10"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "-t=6", "--threads-batch", "10"),
},
{
// Sans -tb, llama.cpp remplacerait tout le réglage CPU du batch par
// celui de -t : le --poll-batch de l'utilisateur serait effacé.
name: "--poll-batch dans EXTRA_ARGS, THREADS vide : -tb 0 le protège, et on le dit",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--poll-batch 0"},
si: serveSysInfo{Help: helpRecent},
want: withThreads([]string{"-tb", "0"}, "--parallel", "1", "-ngl", "28", "--poll-batch", "0"), wantNotes: 1,
},
{
name: "-Cb dans EXTRA_ARGS, THREADS=6 : -tb recopie 6",
cfg: map[string]string{"NGL": "28", "THREADS": "6", "EXTRA_ARGS": "-Cb 0xff"},
si: serveSysInfo{Help: helpRecent},
want: withThreads([]string{"-t", "6", "-tb", "6"}, "--parallel", "1", "-ngl", "28", "-Cb", "0xff"),
},
{
name: "-t=5 et --prio-batch dans EXTRA_ARGS : -tb recopie 5",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-t=5 --prio-batch 2"},
si: serveSysInfo{Help: helpRecent},
want: withThreads([]string{"-tb", "5"}, "--parallel", "1", "-ngl", "28", "-t=5", "--prio-batch", "2"),
},
{
name: "-Crb et -tb dans EXTRA_ARGS : Loki se tait",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-Crb 0-7 -tb 8"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "-Crb", "0-7", "-tb", "8"),
},
{
name: "conteneur à l'étroit : -t de la sonde, et une note",
cfg: map[string]string{"NGL": "28"},
si: serveSysInfo{Help: helpRecent, CPU: cpuBudget{N: 4, Engine: 8, Why: "quota cgroup de 4 CPU"}},
want: withThreads([]string{"-t", "4"}, "--parallel", "1", "-ngl", "28"), wantNotes: 1,
},
{
name: "THREADS explicite : la sonde se tait",
cfg: map[string]string{"NGL": "28", "THREADS": "6"},
si: serveSysInfo{Help: helpRecent, CPU: cpuBudget{N: 4, Engine: 8, Why: "quota"}},
want: withThreads([]string{"-t", "6"}, "--parallel", "1", "-ngl", "28"),
},
{
name: "masque d'affinité dans EXTRA_ARGS : la sonde se tait",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-Cr 0-3"},
si: serveSysInfo{Help: helpRecent, CPU: cpuBudget{N: 4, Engine: 8, Why: "quota"}},
want: with("--parallel", "1", "-ngl", "28", "-Cr", "0-3"),
},
{
name: "cache KV séparé K/V",
cfg: map[string]string{"NGL": "28", "KV_TYPE": "q8_0", "KV_TYPE_V": "q4_0"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "-ctk", "q8_0", "-ctv", "q4_0"), wantNotes: 1,
},
{
// Preset MoE typique : experts sur CPU via EXTRA_ARGS, qui pose aussi
// --parallel, -ngl et le cache KV. Loki n'ajoute alors ni --parallel ni
// -ngl, et EXTRA_ARGS ferme la marche (la dernière occurrence gagne).
name: "MoE : EXTRA_ARGS avec -ot, --n-cpu-moe, -ctk q8_0",
cfg: map[string]string{"NGL": "999", "PARALLEL": "1",
"EXTRA_ARGS": `-ngl 99 --parallel 1 -ot "blk\.(\d+)\.ffn_.*_exps=CPU" --n-cpu-moe 30 -ctk q8_0 -ctv q8_0 -fa on`},
si: serveSysInfo{Help: helpRecent},
want: with("-ngl", "99", "--parallel", "1", "-ot", `blk\.(\d+)\.ffn_.*_exps=CPU`,
"--n-cpu-moe", "30", "-ctk", "q8_0", "-ctv", "q8_0", "-fa", "on"),
wantNotes: 1, // q8_0 venu d'EXTRA_ARGS : vu comme celui de KV_TYPE
},
{
name: "--n-gpu-layers=N dans EXTRA_ARGS : pas de -ngl en double",
cfg: map[string]string{"NGL": "999", "EXTRA_ARGS": "--n-gpu-layers=40 -np 2"},
si: serveSysInfo{Help: helpRecent},
want: with("--n-gpu-layers=40", "-np", "2"),
},
{
name: "raisonnement actif : budget illimité par défaut",
cfg: map[string]string{"NGL": "28", "REASONING": "on"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "--reasoning", "on", "--reasoning-budget", "-1"),
},
{
name: "raisonnement auto avec budget choisi",
cfg: map[string]string{"NGL": "28", "REASONING": " auto ", "REASONING_BUDGET": "4096"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "--reasoning", "auto", "--reasoning-budget", "4096"),
},
{
name: "raisonnement interdit, moteur qui connaît le drapeau",
cfg: map[string]string{"NGL": "28", "REASONING": "off"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "--reasoning", "off"),
},
{
name: "raisonnement interdit, vieux moteur : aucun drapeau, une note",
cfg: map[string]string{"NGL": "28", "REASONING": "off"},
si: serveSysInfo{Help: helpOld},
want: with("--parallel", "1", "-ngl", "28"), wantNotes: 1,
},
{
name: "vision et clé d'API, avant EXTRA_ARGS",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-fa on"},
si: serveSysInfo{Help: helpRecent, MMProj: "/models/mmproj-F16.gguf", APIKey: "secret"},
want: with("--parallel", "1", "-ngl", "28", "--mmproj", "/models/mmproj-F16.gguf",
"--api-key", "secret", "-fa", "on"),
},
{
name: "drapeaux de chargement anciens → --load-mode sur moteur récent",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--mlock --no-mmap -fa on"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "-fa", "on", "--load-mode", "mlock"),
},
{
name: "--load-mode retraduit pour un moteur ancien",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--load-mode none"},
si: serveSysInfo{Help: helpOld},
want: with("--parallel", "1", "-ngl", "28", "--no-mmap"),
},
{
// dio n'existe pas sur l'ancien moteur : retiré, et dit par une note
// RENDUE (buildServeArgs n'écrit rien elle-même).
name: "--load-mode dio sur moteur ancien : retiré, une note",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--load-mode dio -fa on"},
si: serveSysInfo{Help: helpOld},
want: with("--parallel", "1", "-ngl", "28", "-fa", "on"), wantNotes: 1,
},
{
name: "sélection GPU : variables d'environnement, ligne inchangée",
cfg: map[string]string{"NGL": "28", "CUDA_VISIBLE_DEVICES": "1,0"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28"),
wantEnv: map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "CUDA_DEVICE_ORDER": "PCI_BUS_ID"},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
c.si.Model = model
args, env, notes := buildServeArgs(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), bin, c.si)
if !reflect.DeepEqual(args, c.want) {
t.Fatalf("args\n got %q\nwant %q", args, c.want)
}
wantEnv := c.wantEnv
if wantEnv == nil {
wantEnv = map[string]string{}
}
if !reflect.DeepEqual(env, wantEnv) {
t.Fatalf("env = %v, want %v", env, wantEnv)
}
if len(notes) != c.wantNotes {
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
}
})
}
}
func TestHelpCapabilities(t *testing.T) {
if !helpSupportsReasoningFlag(helpRecent) || helpSupportsReasoningFlag(helpOld) {
t.Fatal("--reasoning mal détecté")
}
if !helpSupportsLoadMode(helpRecent) || helpSupportsLoadMode(helpOld) {
t.Fatal("--load-mode mal détecté")
}
if !helpSupportsNGLAuto(helpRecent) || helpSupportsNGLAuto(helpOld) {
t.Fatal("« -ngl auto » mal détecté")
}
// Aide illisible = aucune capacité : on garde la forme historique.
if helpFitsLayersItself("") || helpSupportsReasoningFlag("") {
t.Fatal("une aide vide ne doit promettre aucune capacité")
}
}
func TestServeKVTypes(t *testing.T) {
for _, c := range []struct {
cfg map[string]string
k, v string
}{
{map[string]string{}, "", ""},
{map[string]string{"KV_TYPE": "q8_0"}, "q8_0", "q8_0"},
{map[string]string{"KV_TYPE": "q8_0", "KV_TYPE_K": "f16"}, "f16", "q8_0"},
{map[string]string{"KV_TYPE_V": "q4_0"}, "", "q4_0"},
} {
if k, v := serveKVTypes(c.cfg); k != c.k || v != c.v {
t.Errorf("%v : got %s/%s, want %s/%s", c.cfg, k, v, c.k, c.v)
}
}
}
+576
View File
@@ -0,0 +1,576 @@
package loki
import (
"context"
"fmt"
"io/fs"
"math"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
// Cache de prompts en RAM (--cache-ram) et isolation des travaux annexes.
//
// llama-server garde en RAM hôte une copie EXACTE des états de slot qu'il quitte
// (cache KV, état récurrent, points de reprise, contexte du brouillon MTP) et la
// recharge octet pour octet quand la conversation revient. Ce n'est pas une
// approximation : la seule différence avec un prefill complet est le bruit de
// virgule flottante dû à un découpage des lots différent, exactement comme le
// cache_prompt par défaut. Le type du cache KV n'est pas touché, il est copié
// tel quel.
//
// Le défaut du moteur (8 Gio) est trop petit pour une conversation longue d'un
// modèle dense : l'état principal (30 à 65 k jetons) ne tient plus à côté de
// celui d'un vérificateur ou d'un sous-agent, et la conversation est alors
// recalculée en entier — 30 à 80 s sur un 27B. Loki agrandit donc ce cache,
// mais seulement quand il sait mesurer le besoin et que la RAM est libre : il ne
// descend JAMAIS sous le défaut du moteur, et se tait dès que l'utilisateur a
// tranché (CACHE_RAM, -cram dans EXTRA_ARGS, LLAMA_ARG_CACHE_RAM).
//
// Tout ce qui décide est pur ; probeCacheRAM fait les lectures (GGUF, RAM,
// nvidia-smi) une fois, avant buildServeArgs.
// engineCacheRAMDefault : cache_ram_mib par défaut de llama.cpp (common.h).
const engineCacheRAMDefault = 8192
// cacheArgEnv : la variable qui règle le cache sans drapeau. llama.cpp donne la
// priorité à la ligne de commande : un --cache-ram de Loki l'écraserait.
var cacheArgEnv = []string{"LLAMA_ARG_CACHE_RAM"}
// probeCacheRAM complète si avec ce dont cacheRAMAuto a besoin. Chaque lecture
// ratée laisse un zéro, et un zéro veut dire « on ne sait pas » : le moteur
// garde alors son défaut.
func probeCacheRAM(cfg map[string]string, extra []string, si *serveSysInfo) {
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range cacheArgEnv {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
if si.Model != "" {
si.ModelBytes = shardFamilySize(filepath.Dir(si.Model), filepath.Base(si.Model))
if g, err := ggufMeta(si.Model); err == nil {
si.GGUF = &g
}
}
used, total := ramUsageMB()
if total > 0 {
si.RAMMiB, si.RAMAvailMiB = int64(total), int64(total-used)
}
if runtime.GOOS == "linux" {
if limit, cur := cgroupMemMiB(os.DirFS("/")); limit > 0 {
if si.RAMMiB == 0 || limit < si.RAMMiB {
si.RAMMiB = limit
}
if free := limit - cur; free >= 0 && (si.RAMAvailMiB == 0 || free < si.RAMAvailMiB) {
si.RAMAvailMiB = free
}
}
}
// Mémoire unifiée : Apple Silicon seulement. Un Mac Intel à carte AMD a une
// VRAM à lui, que Loki ne sait pas mesurer (loadModeRisk la tient pour
// inconnue, jamais pour nulle).
si.UnifiedMem = runtime.GOOS == "darwin" && runtime.GOARCH == "arm64"
// macOS, AMD, Vulkan, --device choisi à la main : pas de total VRAM fiable,
// donc pas d'agrandissement.
if runtime.GOOS == "darwin" || hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" {
return
}
// La sélection du preset d'abord (cudaDeviceEnv l'accompagne toujours de
// PCI_BUS_ID), celle de l'environnement ensuite : l'aperçu de l'interface
// tourne dans un process où le preset n'a rien posé.
cvd, pci := cfg["CUDA_VISIBLE_DEVICES"], true
if cvd == "" {
cvd, pci = os.Getenv("CUDA_VISIBLE_DEVICES"), os.Getenv("CUDA_DEVICE_ORDER") == "PCI_BUS_ID"
}
si.VRAMMiB = nvidiaVRAMMiB(cvd, pci, 3*time.Second)
}
// cgroupMemMiB : limite mémoire du conteneur et sa consommation, en Mio (v2,
// sinon v1). 0 = pas de limite. Le MemAvailable de /proc/meminfo voit l'hôte
// entier : sans ça, un conteneur limité à 16 Go sur une machine de 64 Go se
// croirait riche.
func cgroupMemMiB(fsys fs.FS) (limit, current int64) {
read := func(p string) int64 {
b, err := fs.ReadFile(fsys, p)
if err != nil {
return 0
}
n, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
if err != nil || n <= 0 || n >= 1<<60 { // « max » ou la valeur géante du v1 sans limite
return 0
}
return n >> 20
}
if limit = read("sys/fs/cgroup/memory.max"); limit > 0 {
return limit, read("sys/fs/cgroup/memory.current")
}
if limit = read("sys/fs/cgroup/memory/memory.limit_in_bytes"); limit > 0 {
return limit, read("sys/fs/cgroup/memory/memory.usage_in_bytes")
}
return 0, 0
}
// nvidiaVRAMMiB : VRAM totale des cartes que llama-server verra. Borné dans le
// temps comme nvidiaGPUCount ; au moindre doute, 0.
func nvidiaVRAMMiB(cvd string, pciOrder bool, timeout time.Duration) int64 {
out, ok := smiTotals.get(time.Now, timeout)
if !ok {
return 0
}
return vramFromSMI(out, cvd, pciOrder)
}
// nvidiaTotalsQuery : la lecture brute des mémoires totales. Variable pour
// les tests.
var nvidiaTotalsQuery = func(timeout time.Duration) (string, error) {
if !hasTool("nvidia-smi") {
return "", exec.ErrNotFound
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
out, err := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index,memory.total",
"--format=csv,noheader,nounits")).Output()
return string(out), err
}
// smiTotalsCache : la lecture des mémoires totales, partagée quelques
// secondes. L'éditeur de preset demande à l'ouverture la taille du cache de
// prompts (et celle du second slot) et les avis MoE, en parallèle : chacun
// lançait SON nvidia-smi pour une réponse identique — la mémoire totale d'une
// carte ne bouge pas. Le verrou tenu pendant la lecture fait attendre les
// appels simultanés sur la même ; la sélection des cartes (CUDA_VISIBLE_DEVICES)
// s'applique après, sur la sortie brute. Un échec est gardé aussi, le temps du
// délai : la même salve ne relance pas trois lectures qui échoueront.
type smiTotalsCache struct {
mu sync.Mutex
exp time.Time
out string
ok bool
}
const smiTotalsTTL = 10 * time.Second
var smiTotals smiTotalsCache
func (c *smiTotalsCache) get(now func() time.Time, timeout time.Duration) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if now().Before(c.exp) {
return c.out, c.ok
}
out, err := nvidiaTotalsQuery(timeout)
c.out, c.ok, c.exp = out, err == nil, now().Add(smiTotalsTTL)
return c.out, c.ok
}
// vramFromSMI additionne la VRAM des cartes sélectionnées par
// CUDA_VISIBLE_DEVICES. Les index de nvidia-smi sont dans l'ordre du bus PCI ;
// ceux de CUDA aussi, mais seulement avec CUDA_DEVICE_ORDER=PCI_BUS_ID. Sans
// lui, on ne sait pas quelle carte est laquelle : on prend la plus petite autant
// de fois qu'il y a de cartes — un minorant, qui ne fait que rendre Loki plus
// prudent. Un UUID ou une tranche MIG : on ne sait pas, 0.
func vramFromSMI(out, cvd string, pciOrder bool) int64 {
byIdx := map[int]int64{}
var minMiB int64
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
f := strings.Split(line, ",")
if len(f) < 2 {
continue
}
i, err1 := strconv.Atoi(strings.TrimSpace(f[0]))
m, err2 := strconv.ParseInt(strings.TrimSpace(f[1]), 10, 64)
if err1 != nil || err2 != nil || m <= 0 {
return 0
}
byIdx[i] = m
if minMiB == 0 || m < minMiB {
minMiB = m
}
}
if len(byIdx) == 0 {
return 0
}
cvd = strings.TrimSpace(cvd)
if cvd == "" {
var sum int64
for _, m := range byIdx {
sum += m
}
return sum
}
var sum int64
n := 0
for _, d := range strings.Split(cvd, ",") {
if d = strings.TrimSpace(d); d == "" {
continue
}
i, err := strconv.Atoi(d)
if err != nil {
return 0
}
m, ok := byIdx[i]
if !ok {
return 0
}
sum += m
n++
}
if !pciOrder {
return minMiB * int64(n)
}
return sum
}
// kvTypeBytes : octets par élément d'un type de cache (blocs de 32 pour les
// types quantifiés de ggml). Inconnu : compté comme f16, l'estimation reste
// bornée par le plafond de RAM.
func kvTypeBytes(t string) float64 {
switch strings.ToLower(strings.TrimSpace(t)) {
case "f32":
return 4
case "q8_0":
return 34.0 / 32
case "q5_1":
return 24.0 / 32
case "q5_0":
return 22.0 / 32
case "q4_1":
return 20.0 / 32
case "q4_0", "iq4_nl":
return 18.0 / 32
}
return 2
}
// ggufStateBytes estime ce que pèse l'état d'une séquence : octets de cache KV
// par jeton (couches d'attention), et octets d'état récurrent (couches SSM d'un
// hybride, en f32 comme dans llama.cpp). 0 par jeton = on ne sait pas.
func ggufStateBytes(g GGUFInfo, kt, vt string) (perToken float64, recurrent int64) {
kl, vl := g.KeyLen, g.ValLen
if g.EmbeddingLength > 0 && g.HeadCount > 0 {
if kl == 0 {
kl = g.EmbeddingLength / g.HeadCount
}
if vl == 0 {
vl = g.EmbeddingLength / g.HeadCount
}
}
if kl == 0 || vl == 0 || g.BlockCount == 0 {
return 0, 0
}
heads, recLayers := 0, 0
switch {
case len(g.HeadCountKVLayers) > 0:
for _, h := range g.HeadCountKVLayers {
heads += h
if h == 0 && g.Hybrid {
recLayers++
}
}
case g.Hybrid && g.FullAttnInterval > 0:
attn := g.BlockCount / g.FullAttnInterval
heads, recLayers = attn*g.HeadCountKV, g.BlockCount-attn
default:
heads = g.BlockCount * g.HeadCountKV
if heads == 0 && g.Hybrid {
recLayers = g.BlockCount // récurrent pur
}
}
perToken = float64(heads) * (float64(kl)*kvTypeBytes(kt) + float64(vl)*kvTypeBytes(vt))
if recLayers > 0 {
conv := int64(0)
if g.SSMConv > 1 {
conv = int64(g.SSMConv-1) * int64(g.SSMInner+2*g.SSMGroups*g.SSMState)
}
recurrent = int64(recLayers) * (conv + int64(g.SSMState)*int64(g.SSMInner)) * 4
}
return perToken, recurrent
}
// serveCtx : la taille de contexte que le moteur ouvrira (-c d'EXTRA_ARGS, sinon
// CTX, sinon le défaut de Loki ; 0 = celle d'entraînement du modèle).
func serveCtx(cfg map[string]string, extra []string, g *GGUFInfo) int {
s := flagValue(extra, "-c", "--ctx-size")
if s == "" {
s = strings.TrimSpace(cfg["CTX"])
}
if s == "" {
s = "32768"
}
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
return 0
}
if n == 0 && g != nil {
n = g.ContextLength
}
return n
}
// cpuWeights dit pourquoi des poids (ou le cache KV) vivent en RAM hôte. Le
// cache de prompts y ferait alors concurrence au cache de pages des experts
// mappés : on n'y touche pas. Vide = tout est sur GPU.
func cpuWeights(cfg map[string]string, extra []string, argEnv map[string]string, blocks int) string {
ot := flagValues(extra, "-ot", "--override-tensor")
if v := argEnv["LLAMA_ARG_OVERRIDE_TENSOR"]; v != "" {
ot = append(ot, v)
}
for _, v := range ot {
for _, el := range strings.Split(v, ",") {
if _, buft, ok := strings.Cut(el, "="); ok && strings.HasPrefix(strings.ToUpper(strings.TrimSpace(buft)), "CPU") {
return "tenseurs sur CPU (-ot)"
}
}
}
if hasAnyFlag(extra, "--cpu-moe", "-cmoe") || envTruthy(argEnv["LLAMA_ARG_CPU_MOE"]) {
return "experts sur CPU"
}
for _, n := range []struct{ env, short, long string }{
{"LLAMA_ARG_N_CPU_MOE", "-ncmoe", "--n-cpu-moe"},
{"LLAMA_ARG_N_CPU_FFN", "-ncffn", "--n-cpu-ffn"},
} {
for _, v := range append(flagValues(extra, n.short, n.long), argEnv[n.env]) {
if v = strings.TrimSpace(v); v != "" && v != "0" {
return "couches " + n.long + " sur CPU"
}
}
}
if !kvOffloaded(extra, argEnv) {
return "cache KV sur CPU"
}
ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
if ngl == "" {
ngl = strings.TrimSpace(cfg["NGL"])
if strings.EqualFold(ngl, "auto") {
ngl = argEnv["LLAMA_ARG_N_GPU_LAYERS"]
}
}
// 999 est la sentinelle « toutes » ; un nombre plus petit que le modèle (sa
// couche de sortie comprise) laisse des couches au CPU.
if n, err := strconv.Atoi(strings.TrimSpace(ngl)); err == nil && n != 999 && (blocks == 0 || n < blocks+1) {
return "couches GPU limitées à " + strconv.Itoa(n)
}
return ""
}
// cacheRAMAuto calcule la taille du cache de prompts quand CACHE_RAM est vide
// ou « auto ». 0 = garder le défaut du moteur, why dit pourquoi.
//
// Besoin : 2,5 fois l'état d'une conversation pleine (cache KV à CTX jetons,
// plus l'état récurrent et ses points de reprise sur un hybride) — de quoi
// garder la conversation principale ET l'état d'un travail annexe. Plafond : 30
// % de la RAM (limite du conteneur comprise) et la moitié de ce qui est libre au
// lancement. Ce plafond est un réglage, pas un filet : sous Linux, un
// dépassement finit en général par l'OOM killer, pas par le repli du moteur.
func cacheRAMAuto(cfg map[string]string, extra []string, si serveSysInfo) (mib int64, why string) {
switch {
case si.VRAMMiB <= 0:
return 0, "VRAM NVIDIA inconnue"
case si.GGUF == nil:
return 0, "métadonnées GGUF illisibles"
case si.RAMMiB <= 0 || si.RAMAvailMiB <= 0:
return 0, "RAM inconnue"
case si.ModelBytes <= 0:
return 0, "taille du modèle inconnue"
}
if r := cpuWeights(cfg, extra, si.ArgEnv, si.GGUF.BlockCount); r != "" {
return 0, r
}
kt, vt, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
perTok, rec := ggufStateBytes(*si.GGUF, kt, vt)
ctx := serveCtx(cfg, extra, si.GGUF)
if perTok <= 0 || ctx <= 0 {
return 0, "taille d'état inconnue"
}
kvBytes := perTok * float64(ctx)
if float64(si.ModelBytes)+kvBytes > 0.9*float64(si.VRAMMiB)*(1<<20) {
return 0, "modèle trop gros pour la VRAM : des poids restent en RAM"
}
state := kvBytes
if rec > 0 {
// Points de reprise : llama.cpp en garde jusqu'à 32 par séquence ; on en
// compte un par tranche de 8 k jetons, estimation grossière mais bornée.
state += float64(rec) * float64(min(32, ctx/8192+1)+1)
}
need := int64(math.Ceil(2.5 * state / (1 << 20)))
ceiling := min(si.RAMMiB*30/100, si.RAMAvailMiB/2)
if ceiling <= engineCacheRAMDefault {
return 0, fmt.Sprintf("RAM libre trop juste (plafond %d Mio)", ceiling)
}
if need <= engineCacheRAMDefault {
return 0, fmt.Sprintf("le défaut suffit (besoin estimé %d Mio)", need)
}
return min(need, ceiling), fmt.Sprintf("besoin estimé %d Mio, plafond %d Mio", need, ceiling)
}
// cacheRAMSetting lit CACHE_RAM : auto (vide ou « auto »), ou un nombre de Mio
// passé tel quel (-1 = sans limite, 0 = cache coupé). ok=false : valeur
// illisible.
func cacheRAMSetting(cfg map[string]string) (manual string, auto, ok bool) {
v := strings.TrimSpace(cfg["CACHE_RAM"])
if v == "" || strings.EqualFold(v, "auto") {
return "", true, true
}
if n, err := strconv.Atoi(v); err == nil && n >= -1 {
return strconv.Itoa(n), false, true
}
return "", false, false
}
// cacheRAMArgs : le --cache-ram que Loki ajoute, et ce qu'il en dit.
func cacheRAMArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
manual, auto, ok := cacheRAMSetting(cfg)
if !ok {
return nil, []string{"CACHE_RAM=" + cfg["CACHE_RAM"] + " illisible (un nombre de Mio, -1, 0 ou auto) : ignoré"}
}
if !strings.Contains(si.Help, "--cache-ram") {
if !auto {
notes = append(notes, "ce moteur ne connaît pas --cache-ram : CACHE_RAM ignoré")
}
return nil, notes
}
if hasAnyFlag(extra, "-cram", "--cache-ram") {
return nil, nil
}
if si.ArgEnv["LLAMA_ARG_CACHE_RAM"] != "" {
if !auto {
notes = append(notes, "LLAMA_ARG_CACHE_RAM est posée : CACHE_RAM ignoré")
}
return nil, notes
}
if !auto {
return []string{"--cache-ram", manual}, nil
}
n, why := cacheRAMAuto(cfg, extra, si)
if n <= 0 {
return nil, nil
}
return []string{"--cache-ram", strconv.FormatInt(n, 10)},
[]string{fmt.Sprintf("cache de prompts : --cache-ram %d (RAM hôte, copie exacte de l'état, jamais la VRAM ; %s). "+
"CACHE_RAM=<Mio> pour fixer, 0 pour couper.", n, why)}
}
// cacheRAMOff : le cache de prompts est-il coupé (0) par l'une des trois
// sources ? Sans lui, effacer un slot ne protège rien.
func cacheRAMOff(cfg map[string]string, extra []string, argEnv map[string]string) bool {
v := flagValue(extra, "-cram", "--cache-ram")
if v == "" {
v = argEnv["LLAMA_ARG_CACHE_RAM"]
}
if v == "" {
v, _, _ = cacheRAMSetting(cfg)
}
return strings.TrimSpace(v) == "0"
}
// cacheIsolationOn : CACHE_ISOLATE=off coupe l'effacement du slot après les
// travaux annexes (et donc --slot-save-path), sans toucher au code.
func cacheIsolationOn(cfg map[string]string) bool {
return !strings.EqualFold(strings.TrimSpace(cfg["CACHE_ISOLATE"]), "off")
}
// loopbackHost : le moteur n'écoute que sur la machine.
func loopbackHost(h string) bool {
switch strings.Trim(strings.TrimSpace(h), "[]") {
case "127.0.0.1", "::1", "localhost":
return true
}
return false
}
// slotSaveArgs : --slot-save-path, sans lequel llama-server refuse toute action
// sur /slots — l'effacement compris. Mais le drapeau ouvre aussi save et
// restore, qui écrivent des Gio sur le disque, à quiconque joint le moteur. On
// ne le pose donc que si le moteur est fermé aux autres (boucle locale, ou clé
// d'API exigée), que le dossier existe (le moteur refuse de démarrer sinon), et
// qu'il servira : à l'isolation (cache actif, CACHE_ISOLATE pas à off), ou à
// SLOT_PERSIST accepté (backend_serve_persist.go). Les proxys de Loki refusent
// de leur côté tout /slots qui n'est pas une lecture.
func slotSaveArgs(cfg map[string]string, extra []string, si serveSysInfo) []string {
if si.SlotDir == "" || !strings.Contains(si.Help, "--slot-save-path") || hasAnyFlag(extra, "--slot-save-path") {
return nil
}
persist, _ := slotPersistPlan(cfg, extra, si)
if !persist && (!cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv)) {
return nil
}
if !slotSaveSafe(cfg, extra, si) {
return nil
}
return []string{"--slot-save-path", si.SlotDir}
}
// prepareSlotDir crée LOKI_HOME/slots (0700) et le vide : un fichier présent ne
// peut venir que d'un appel qui n'aurait pas dû passer. Seule exception, avec
// keepPersist (SLOT_PERSIST demandé) : les états que Loki y a lui-même gardés
// à la bascule de preset, au nom reconnaissable. Vide = dossier indisponible,
// le drapeau ne sera pas posé.
func prepareSlotDir(home string, keepPersist bool) string {
dir := filepath.Join(home, "slots")
if err := os.MkdirAll(dir, 0o700); err != nil {
return ""
}
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
return ""
}
if entries, err := os.ReadDir(dir); err == nil {
for _, e := range entries {
if e.Type().IsRegular() && !(keepPersist && slotPersistFileRe.MatchString(e.Name())) {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
}
return dir
}
// cacheRAMPreview : la taille que le moteur prendra pour ce preset, pour
// l'éditeur. 0 = défaut du moteur (8192 Mio). Même calcul qu'au lancement, mais
// sans l'aide du moteur : l'aperçu dit ce que Loki demanderait.
func cacheRAMPreview(content string) (mib int64, why string) {
cfg := parseEnv(content)
if isExternalConfig(cfg) {
return 0, "preset externe"
}
extra := splitArgs(cfg["EXTRA_ARGS"])
if v := flagValue(extra, "-cram", "--cache-ram"); v != "" {
n, _ := strconv.ParseInt(v, 10, 64)
return n, "fixé par EXTRA_ARGS"
}
if v := strings.TrimSpace(os.Getenv("LLAMA_ARG_CACHE_RAM")); v != "" {
n, _ := strconv.ParseInt(v, 10, 64)
return n, "fixé par LLAMA_ARG_CACHE_RAM"
}
manual, auto, ok := cacheRAMSetting(cfg)
if !ok {
return 0, "CACHE_RAM illisible"
}
if !auto {
n, _ := strconv.ParseInt(manual, 10, 64)
return n, "fixé par CACHE_RAM"
}
si := serveSysInfo{ArgEnv: map[string]string{}}
for _, k := range append(append(append([]string{}, serveArgEnv...), fidelityArgEnv...), cacheArgEnv...) {
if v, ok := os.LookupEnv(k); ok && v != "" {
si.ArgEnv[k] = v
}
}
if m := strings.TrimSpace(cfg["MODEL"]); m != "" {
if p, err := resolveServeModelPath(m); err == nil {
si.Model = p
}
}
if si.Model == "" {
return 0, "modèle introuvable"
}
probeCacheRAM(cfg, extra, &si)
return cacheRAMAuto(cfg, extra, si)
}
+319
View File
@@ -0,0 +1,319 @@
package loki
import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"testing/fstest"
"time"
)
const helpCacheRAM = helpRecent + `-cram, --cache-ram N set the maximum cache size in MiB (default: 8192, -1 - no limit, 0 - disable)
--slot-save-path PATH path to save slot kv cache (default: disabled)
`
// denseGPU : un 27B dense de 17 Go sur deux cartes de 24 Go, machine de 64 Go.
// État : 64 couches × 4 têtes KV × (256+256) × 2 octets = 0,25 Mio par jeton.
func denseGPU() serveSysInfo {
return serveSysInfo{
Help: helpCacheRAM,
Model: "/models/dense.gguf",
GGUF: &GGUFInfo{Arch: "qwen3", BlockCount: 64, HeadCountKV: 4, KeyLen: 256, ValLen: 256,
ContextLength: 262144},
ModelBytes: 17 << 30,
RAMMiB: 65536, RAMAvailMiB: 60000,
VRAMMiB: 2 * 24576,
ArgEnv: map[string]string{},
}
}
func TestCacheRAMArgs(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
notes int
}{
{name: "dense sur GPU, 64 Go, 65k : besoin 40 Gio, plafonné à 30 % de la RAM",
cfg: map[string]string{"CTX": "65536"}, want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "contexte court : le défaut du moteur suffit",
cfg: map[string]string{"CTX": "8192"}},
{name: "cache q8_0 : l'état pèse deux fois moins, besoin sous le plafond",
cfg: map[string]string{"CTX": "32768", "KV_TYPE": "q8_0"}, want: []string{"--cache-ram", "10880"}, notes: 1},
{name: "même contexte en f16 : plafonné",
cfg: map[string]string{"CTX": "32768"}, want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "-c d'EXTRA_ARGS l'emporte sur CTX",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-c", "8192"}},
{name: "MoE, experts sur CPU par -ot : jamais touché (pas de 4096)",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "exps=CPU"}},
{name: "-ot mixte avec un élément sur CPU",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "a=CUDA0,b=CPU"}},
{name: "-ot vers une autre carte seulement : tout reste en VRAM",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "blk\\.6.*=CUDA1"},
want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "--n-cpu-moe",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"--n-cpu-moe", "30"}},
{name: "LLAMA_ARG_N_CPU_MOE",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_N_CPU_MOE"] = "20" }},
{name: "cache KV sur CPU",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-nkvo"}},
{name: "NGL plus petit que le modèle",
cfg: map[string]string{"CTX": "65536", "NGL": "40"}},
{name: "NGL=999 : toutes les couches",
cfg: map[string]string{"CTX": "65536", "NGL": "999"}, want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "modèle trop gros pour la VRAM",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ModelBytes = 40 << 30 }},
{name: "pas de VRAM connue (macOS, AMD, sans nvidia-smi)",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.VRAMMiB = 0 }},
{name: "GGUF illisible",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.GGUF = nil }},
{name: "RAM libre trop juste",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.RAMAvailMiB = 12000 }},
{name: "CACHE_RAM fixé : passé tel quel",
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "12000"}, want: []string{"--cache-ram", "12000"}},
{name: "CACHE_RAM=-1 : sans limite, passé tel quel",
cfg: map[string]string{"CACHE_RAM": "-1"}, want: []string{"--cache-ram", "-1"}},
{name: "CACHE_RAM=0 : cache coupé",
cfg: map[string]string{"CACHE_RAM": "0"}, want: []string{"--cache-ram", "0"}},
{name: "CACHE_RAM=auto",
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "auto"}, want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "CACHE_RAM illisible : ignoré, et dit",
cfg: map[string]string{"CACHE_RAM": "beaucoup"}, notes: 1},
{name: "-cram d'EXTRA_ARGS : Loki se tait",
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "12000"}, extra: []string{"-cram", "2048"}},
{name: "LLAMA_ARG_CACHE_RAM : la ligne de commande l'écraserait, Loki se tait",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CACHE_RAM"] = "4096" }},
{name: "LLAMA_ARG_CACHE_RAM et CACHE_RAM : la variable gagne, et on le dit",
cfg: map[string]string{"CACHE_RAM": "12000"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CACHE_RAM"] = "4096" }, notes: 1},
{name: "moteur sans --cache-ram : rien, même fixé",
cfg: map[string]string{"CACHE_RAM": "12000"}, si: func(s *serveSysInfo) { s.Help = helpRecent }, notes: 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := denseGPU()
if c.si != nil {
c.si(&si)
}
got, notes := cacheRAMArgs(c.cfg, c.extra, si)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("args = %v, attendu %v (notes %v)", got, c.want, notes)
}
if len(notes) != c.notes {
t.Fatalf("notes = %v, attendu %d", notes, c.notes)
}
})
}
}
// Hybride (Qwen3.5) : 16 couches d'attention sur 64, état récurrent des 48
// autres, compté avec ses points de reprise.
func TestCacheRAMAutoHybride(t *testing.T) {
si := denseGPU()
layers := make([]int, 64)
for i := range layers {
if (i+1)%4 == 0 {
layers[i] = 4
}
}
si.GGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 64, HeadCountKV: 4, HeadCountKVLayers: layers,
KeyLen: 256, ValLen: 256, FullAttnInterval: 4, Hybrid: true,
SSMConv: 4, SSMInner: 6144, SSMState: 128, SSMGroups: 16}
perTok, rec := ggufStateBytes(*si.GGUF, "", "")
if perTok != 65536 || rec != 156893184 {
t.Fatalf("état = %v o/jeton, %d o récurrents", perTok, rec)
}
// 4 Gio de KV + 10 états récurrents (9 points de reprise + l'état courant),
// × 2,5.
n, why := cacheRAMAuto(map[string]string{"CTX": "65536"}, nil, si)
if n != 13981 {
t.Fatalf("cacheRAMAuto = %d (%s), attendu 13981", n, why)
}
}
// Sans key_length, llama.cpp prend n_embd / n_head : nous aussi.
func TestGGUFStateBytesReplis(t *testing.T) {
g := GGUFInfo{BlockCount: 2, HeadCountKV: 2, EmbeddingLength: 1024, HeadCount: 8}
if p, _ := ggufStateBytes(g, "", ""); p != 2*2*(128+128)*2 {
t.Fatalf("repli n_embd/n_head : %v", p)
}
if p, _ := ggufStateBytes(GGUFInfo{BlockCount: 2, HeadCountKV: 2}, "", ""); p != 0 {
t.Fatalf("dimensions inconnues : %v, attendu 0", p)
}
}
func TestVRAMFromSMI(t *testing.T) {
const out = "0, 24576\n1, 16384\n"
cases := []struct {
cvd string
pci bool
want int64
}{
{"", false, 40960},
{"1", true, 16384},
{"0,1", true, 40960},
{"1", false, 16384}, // ordre inconnu : la plus petite carte
{"0", false, 16384},
{"GPU-1234", true, 0},
{"2", true, 0},
}
for _, c := range cases {
if got := vramFromSMI(out, c.cvd, c.pci); got != c.want {
t.Errorf("vramFromSMI(%q, %v) = %d, attendu %d", c.cvd, c.pci, got, c.want)
}
}
if got := vramFromSMI("[N/A]\n", "", false); got != 0 {
t.Errorf("sortie illisible : %d", got)
}
}
func TestCgroupMemMiB(t *testing.T) {
v2 := fstest.MapFS{
"sys/fs/cgroup/memory.max": {Data: []byte("17179869184\n")},
"sys/fs/cgroup/memory.current": {Data: []byte("4294967296\n")},
}
if l, c := cgroupMemMiB(v2); l != 16384 || c != 4096 {
t.Fatalf("v2 = %d/%d", l, c)
}
if l, _ := cgroupMemMiB(fstest.MapFS{"sys/fs/cgroup/memory.max": {Data: []byte("max\n")}}); l != 0 {
t.Fatalf("v2 sans limite = %d", l)
}
v1 := fstest.MapFS{"sys/fs/cgroup/memory/memory.limit_in_bytes": {Data: []byte("9223372036854771712\n")}}
if l, _ := cgroupMemMiB(v1); l != 0 {
t.Fatalf("v1 sans limite = %d", l)
}
}
func TestSlotSaveArgs(t *testing.T) {
want := []string{"--slot-save-path", "/data/slots"}
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
}{
{name: "boucle locale", cfg: map[string]string{"HOST": "127.0.0.1"}, want: want},
{name: "--host ::1 dans EXTRA_ARGS", extra: []string{"--host", "::1"}, want: want},
{name: "0.0.0.0 sans clé : ouvert à tous, pas de drapeau", cfg: map[string]string{}},
{name: "0.0.0.0 avec clé d'API", cfg: map[string]string{"HOST": "0.0.0.0"},
si: func(s *serveSysInfo) { s.APIKey = "k" }, want: want},
{name: "EXTRA_ARGS le pose déjà", cfg: map[string]string{"HOST": "127.0.0.1"},
extra: []string{"--slot-save-path", "/ailleurs"}},
{name: "CACHE_ISOLATE=off", cfg: map[string]string{"HOST": "127.0.0.1", "CACHE_ISOLATE": "off"}},
{name: "cache coupé : rien à isoler", cfg: map[string]string{"HOST": "127.0.0.1", "CACHE_RAM": "0"}},
{name: "cache coupé par EXTRA_ARGS", cfg: map[string]string{"HOST": "127.0.0.1"}, extra: []string{"-cram", "0"}},
{name: "moteur sans le drapeau", cfg: map[string]string{"HOST": "127.0.0.1"},
si: func(s *serveSysInfo) { s.Help = helpRecent }},
{name: "dossier indisponible", cfg: map[string]string{"HOST": "127.0.0.1"},
si: func(s *serveSysInfo) { s.SlotDir = "" }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := serveSysInfo{Help: helpCacheRAM, SlotDir: "/data/slots", ArgEnv: map[string]string{}}
if c.si != nil {
c.si(&si)
}
if got := slotSaveArgs(c.cfg, c.extra, si); !reflect.DeepEqual(got, c.want) {
t.Fatalf("slotSaveArgs = %v, attendu %v", got, c.want)
}
})
}
}
// Place dans la ligne complète : après --api-key, avant EXTRA_ARGS qui garde le
// dernier mot. Sans aide qui les connaisse, la ligne historique est intacte
// (TestBuildServeArgs).
func TestBuildServeArgsCacheRAM(t *testing.T) {
si := denseGPU()
si.APIKey, si.SlotDir = "k", "/data/slots"
args, _, _ := buildServeArgs(map[string]string{"CTX": "65536", "NGL": "999"}, []string{"--jinja"}, "/bin/llama-server", si)
got := strings.Join(args, " ")
if !strings.HasSuffix(got, "--api-key k --cache-ram 19660 --slot-save-path /data/slots --jinja") {
t.Fatalf("ligne = %s", got)
}
}
func TestPrepareSlotDir(t *testing.T) {
home := t.TempDir()
dir := prepareSlotDir(home, false)
if dir != filepath.Join(home, "slots") {
t.Fatalf("dossier = %q", dir)
}
stray := filepath.Join(dir, "slot0.bin")
if err := os.WriteFile(stray, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if prepareSlotDir(home, false) == "" {
t.Fatal("second appel")
}
if _, err := os.Stat(stray); !os.IsNotExist(err) {
t.Fatal("un fichier laissé dans slots/ doit être purgé au lancement")
}
// Un fichier à la place du dossier : pas de drapeau, plutôt qu'un moteur
// qui refuse de démarrer.
bad := t.TempDir()
if err := os.WriteFile(filepath.Join(bad, "slots"), nil, 0o600); err != nil {
t.Fatal(err)
}
if got := prepareSlotDir(bad, false); got != "" {
t.Fatalf("slots est un fichier : %q", got)
}
}
// L'éditeur demande en parallèle le cache de prompts, le second slot et les
// avis MoE : une seule lecture nvidia-smi pour tous, la sélection des cartes
// appliquée ensuite. Après le délai, une lecture neuve ; un échec n'est pas
// relancé dans la même salve.
func TestNvidiaVRAMShared(t *testing.T) {
var calls atomic.Int32
fail := false
prevQ := nvidiaTotalsQuery
nvidiaTotalsQuery = func(time.Duration) (string, error) {
calls.Add(1)
time.Sleep(20 * time.Millisecond) // les appels simultanés attendent la même lecture
if fail {
return "", errors.New("exit status 9")
}
return "0, 16311\n1, 12288\n", nil
}
t.Cleanup(func() {
nvidiaTotalsQuery = prevQ
smiTotals.mu.Lock()
smiTotals.exp = time.Time{} // les tests suivants relisent nvidia-smi
smiTotals.mu.Unlock()
})
expire := func() {
smiTotals.mu.Lock()
smiTotals.exp = time.Time{}
smiTotals.mu.Unlock()
}
expire()
var wg sync.WaitGroup
got := make([]int64, 3)
for i, cvd := range []string{"", "0", "1"} {
wg.Add(1)
go func() { defer wg.Done(); got[i] = nvidiaVRAMMiB(cvd, true, time.Second) }()
}
wg.Wait()
if n := calls.Load(); n != 1 {
t.Errorf("%d lectures nvidia-smi pour trois demandes simultanées", n)
}
if !reflect.DeepEqual(got, []int64{16311 + 12288, 16311, 12288}) {
t.Errorf("sélection des cartes : %v", got)
}
expire()
fail = true
if v := nvidiaVRAMMiB("", true, time.Second); v != 0 {
t.Errorf("échec : %d, attendu 0", v)
}
if v := nvidiaVRAMMiB("", true, time.Second); v != 0 || calls.Load() != 2 {
t.Errorf("échec relancé dans la même salve : %d lectures", calls.Load())
}
}
+332
View File
@@ -0,0 +1,332 @@
package loki
import (
"fmt"
"os"
"strconv"
"strings"
"sync"
)
// Points de reprise des modèles hybrides (--ctx-checkpoints,
// --checkpoint-min-step) et conservation du raisonnement (--reasoning-preserve).
//
// Un modèle hybride (couches récurrentes : Qwen3.5/3.6, Qwen3-Next…) ne sait pas
// rembobiner son état récurrent comme un cache KV : llama-server en garde des
// copies exactes, les points de reprise, et repart du plus proche quand la
// conversation diverge ou revient du cache de prompts. Avant b10864 (PR #28302),
// le moteur effaçait un point trop proche du précédent (moins de 8192 jetons)
// MÊME quand la liste avait de la place : la reprise retombait jusqu'à ~8 k
// jetons en arrière, recalculés à chaque tour d'outil. Rien de ce que voit le
// modèle ne change ici — un point de reprise est une copie octet pour octet de
// l'état — seul le temps de prefill, et la RAM hôte que ces copies occupent.
//
// Loki ne touche à rien sans savoir : le build n'est cru que pour un moteur
// officiel (voir engineBuildTrusted), le drapeau n'est posé que si l'aide le
// connaît, et l'utilisateur garde le dernier mot (clés du preset, EXTRA_ARGS,
// LLAMA_ARG_*). Tout ce qui décide est pur ; cmdServe fait les lectures.
const (
// engineMinRecommended : premier build où l'espacement minimal n'évince un
// point de reprise que lorsque la liste est pleine (PR #28302, 5d806aa).
engineMinRecommended = 10864
// engineBuildFloor : en dessous, le numéro ne prouve rien. Un llama.cpp
// compilé depuis un clone superficiel (--depth=1) annonce « build 1 », un
// fork numérote à sa façon : traiter ça comme un moteur ancien poserait un
// avis faux et un drapeau inutile sur un moteur récent.
engineBuildFloor = 5000
// engineCkptDefault : n_ctx_checkpoints par défaut de llama.cpp (common.h).
engineCkptDefault = 32
// ckptAutoMinStep : espacement posé d'office sur un moteur ancien. Plus serré
// que les 8192 du moteur, il garde des points qu'il aurait jetés ; jamais 0,
// qui laisserait l'éviction FIFO jeter le point du début de la conversation
// dans une longue boucle d'outils.
ckptAutoMinStep = 2048
// ckptFallbackMiB : poids d'un point de reprise quand le GGUF ne permet pas de
// le calculer (70 à 200 Mio mesurés sur les hybrides Qwen) — on prend le haut.
ckptFallbackMiB = 200
)
// ckptArgEnv : les variables qui règlent la même chose sans drapeau. llama.cpp
// les lit avant la ligne de commande : un drapeau de Loki les écraserait.
var ckptArgEnv = []string{"LLAMA_ARG_CTX_CHECKPOINTS", "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT",
"LLAMA_ARG_REASONING_PRESERVE", "LLAMA_ARG_CHAT_TEMPLATE_KWARGS"}
// probeCkptEnv complète ArgEnv avec ckptArgEnv.
func probeCkptEnv(si *serveSysInfo) {
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range ckptArgEnv {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
}
// hybridArchs : architectures hybrides connues, au cas où le GGUF ne porterait
// pas de clé ssm.* lisible.
var hybridArchs = map[string]bool{"qwen35": true, "qwen35moe": true, "qwen3next": true, "qwen4exp": true}
// ggufHybrid : le modèle a-t-il des couches récurrentes ? Jamais deviné d'après
// le nom du fichier : sans métadonnées, on répond non.
func ggufHybrid(g *GGUFInfo) bool {
return g != nil && (g.Hybrid || hybridArchs[strings.ToLower(g.Arch)])
}
// engineSource classe un moteur comme le panneau Moteur : celui de l'image,
// un moteur téléchargé par Loki depuis l'image officielle, le précompilé des
// releases officielles, ou tout le reste (« custom » : compilé, fork, chemin
// à la main).
func engineSource(bin string) string {
imageBin := providedEngineBin()
switch {
case imageBin != "" && samePath(bin, imageBin):
return "image"
case engineOwns(bin):
return "downloaded"
case prebuiltOwns(bin):
return "prebuilt"
}
return "custom"
}
// engineBuildTrust : le build n'est cru que pour un moteur publié par ggml-org,
// dont la CI numérote sur l'historique complet, et s'il est vraisemblable. 0 =
// inconnu : ni avis, ni drapeau automatique.
func engineBuildTrust(source string, build int) int {
if source == "custom" || build < engineBuildFloor {
return 0
}
return build
}
// engineBuildTrusted : build du moteur, ou 0 s'il n'est pas digne de confiance.
// Le « --version » est gardé par binaire, comme l'aide (binHelp), et la clé
// porte la taille et la date du fichier : un moteur recompilé au même chemin
// est relu.
func engineBuildTrusted(bin string) int {
src := engineSource(bin)
if src == "custom" {
return 0 // pas la peine de lancer le binaire
}
return engineBuildTrust(src, engineBuildCached(bin))
}
var (
buildMu sync.Mutex
buildCache = map[string]int{}
)
func engineBuildCached(bin string) int {
st, err := os.Stat(bin)
if err != nil {
return 0
}
key := fmt.Sprintf("%s|%d|%d", bin, st.Size(), st.ModTime().UnixNano())
buildMu.Lock()
defer buildMu.Unlock()
if b, ok := buildCache[key]; ok {
return b
}
b, _ := engineBuildOf(bin)
buildCache[key] = b
return b
}
// engineBuildNotice : l'avis du panneau Moteur et du journal de lancement pour
// un build trop ancien. Vide = rien à dire (à jour, ou build inconnu).
func engineBuildNotice(build int) string {
if build <= 0 || build >= engineMinRecommended {
return ""
}
return fmt.Sprintf("moteur b%d : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont "+
"évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b%d.",
build, engineMinRecommended)
}
// ckptCount : nombre de points de reprise par slot que le moteur gardera
// (CTX_CHECKPOINTS valide, sinon EXTRA_ARGS, sinon la variable, sinon le
// défaut). -1 = illisible ; un CTX_CHECKPOINTS illisible est ignoré (voir
// ckptArgs), le moteur garde alors son défaut.
func ckptCount(cfg map[string]string, extra []string, argEnv map[string]string) int {
v := flagValue(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints")
if v == "" {
v = argEnv["LLAMA_ARG_CTX_CHECKPOINTS"]
}
if v == "" {
if n, err := strconv.Atoi(strings.TrimSpace(cfg["CTX_CHECKPOINTS"])); err == nil && n >= 0 {
return n
}
return engineCkptDefault
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || n < 0 {
return -1
}
return n
}
// ckptSlots : nombre de slots que le moteur ouvrira, chacun avec sa propre liste
// de points de reprise. --parallel d'EXTRA_ARGS, sinon PARALLEL, sinon 1 (Loki
// pose toujours --parallel, voir buildServeArgs). Illisible ou « auto » (-1) :
// 4, ce que choisissent les moteurs récents — on compte large.
func ckptSlots(cfg map[string]string, extra []string) int {
v := flagValue(extra, "-np", "--parallel")
if v == "" {
v = strings.TrimSpace(cfg["PARALLEL"])
}
if v == "" {
return 1
}
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
return n
}
return 4
}
// ckptMiB : poids d'un point de reprise d'un hybride (état récurrent en f32).
func ckptMiB(g *GGUFInfo) int64 {
if g != nil {
if _, rec := ggufStateBytes(*g, "", ""); rec > 0 {
return (rec + (1 << 20) - 1) >> 20
}
}
return ckptFallbackMiB
}
// ramTight : le modèle occupe plus de 70 % de la RAM (mmap d'un gros MoE dont
// les experts vivent dans le cache de pages). Chaque Mio de points de reprise
// y est pris aux poids : plus de lectures disque, décodage plus lent.
func ramTight(si serveSysInfo) bool {
return si.RAMMiB > 0 && si.ModelBytes > 0 && float64(si.ModelBytes) > 0.7*float64(si.RAMMiB)*(1<<20)
}
// ckptArgs : --ctx-checkpoints et --checkpoint-min-step que Loki ajoute, et ce
// qu'il en dit.
//
// CTX_CHECKPOINTS=<n> → --ctx-checkpoints n (opt-in ; jamais de défaut relevé)
// CKPT_MIN_STEP=<n> → --checkpoint-min-step n (n > 0)
// clé absente → --checkpoint-min-step 2048 seulement sur un hybride,
// moteur officiel < b10864, RAM non serrée
//
// Chaque drapeau a sa propre garde : un --ctx-checkpoints écrit à la main ne
// coupe pas l'atténuation de l'espacement, qui est une autre question.
func ckptArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
hybrid := ggufHybrid(si.GGUF)
if v := strings.TrimSpace(cfg["CTX_CHECKPOINTS"]); v != "" {
n, err := strconv.Atoi(v)
switch {
case err != nil || n < 0:
notes = append(notes, "CTX_CHECKPOINTS="+v+" illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré")
case !strings.Contains(si.Help, "--ctx-checkpoints"):
notes = append(notes, "ce moteur ne connaît pas --ctx-checkpoints : CTX_CHECKPOINTS ignoré")
case hasAnyFlag(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints"):
case si.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] != "":
notes = append(notes, "LLAMA_ARG_CTX_CHECKPOINTS est posée : CTX_CHECKPOINTS ignoré")
default:
args = append(args, "--ctx-checkpoints", strconv.Itoa(n))
}
} else if hybrid && ramTight(si) && strings.Contains(si.Help, "--ctx-checkpoints") &&
!hasAnyFlag(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints") && si.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] == "" {
// Un conseil, pas un réglage : moins de points de reprise, c'est plus de
// prefill, et seul l'utilisateur peut mesurer lequel des deux coûte le plus.
notes = append(notes, fmt.Sprintf("modèle hybride de %d Gio pour %d Gio de RAM : jusqu'à %d points de reprise "+
"(~%d Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.",
si.ModelBytes>>30, si.RAMMiB>>10, engineCkptDefault, ckptMiB(si.GGUF)))
}
stepUser := hasAnyFlag(extra, "-cms", "--checkpoint-min-step") || si.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] != ""
if v := strings.TrimSpace(cfg["CKPT_MIN_STEP"]); v != "" {
n, err := strconv.Atoi(v)
switch {
case err != nil || n <= 0:
// 0 = « aucun minimum » pour le moteur : la liste se remplit de points
// serrés et l'éviction FIFO finit par jeter celui du début de la
// conversation. On ne le pose pas, sans se taire pour autant.
notes = append(notes, "CKPT_MIN_STEP="+v+" refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter "+
"le point de reprise du début de la conversation) : défaut du moteur")
case !strings.Contains(si.Help, "--checkpoint-min-step"):
notes = append(notes, "ce moteur ne connaît pas --checkpoint-min-step : CKPT_MIN_STEP ignoré")
case hasAnyFlag(extra, "-cms", "--checkpoint-min-step"):
case si.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] != "":
notes = append(notes, "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT est posée : CKPT_MIN_STEP ignoré")
default:
args = append(args, "--checkpoint-min-step", strconv.Itoa(n))
}
return args, notes
}
notice := engineBuildNotice(si.EngineBuild)
if !hybrid || notice == "" {
return args, notes
}
n := ckptCount(cfg, extra, si.ArgEnv)
switch {
case stepUser || !strings.Contains(si.Help, "--checkpoint-min-step"):
return args, append(notes, notice)
case n <= 0:
return args, append(notes, notice) // points de reprise coupés : l'espacement ne sert à rien
case si.RAMMiB <= 0 || si.ModelBytes <= 0 || ramTight(si):
return args, append(notes, notice+" Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle "+
"(CKPT_MIN_STEP=2048 pour l'imposer).")
}
// La liste est PAR SLOT : avec PARALLEL=4, quatre listes se remplissent.
total := n * ckptSlots(cfg, extra)
worst := int64(total) * ckptMiB(si.GGUF)
if si.RAMAvailMiB > 0 && worst > si.RAMAvailMiB/4 {
return args, append(notes, fmt.Sprintf("%s Pas d'espacement resserré d'office : %d points de reprise (~%d Mio) "+
"dépasseraient le quart de la RAM libre (CKPT_MIN_STEP=2048 pour l'imposer).", notice, total, worst))
}
args = append(args, "--checkpoint-min-step", strconv.Itoa(ckptAutoMinStep))
return args, append(notes, fmt.Sprintf("%s → --checkpoint-min-step %d : points de reprise exacts plus rapprochés, "+
"~%d Mio de RAM hôte au plus. CKPT_MIN_STEP=<jetons> pour fixer.", notice, ckptAutoMinStep, worst))
}
// reasoningPreserveArgs : REASONING_PRESERVE=on|off → --reasoning-preserve /
// --no-reasoning-preserve, seulement si la clé est posée.
//
// Le compromis, qui explique pourquoi Loki ne choisit pas : depuis b10763 le
// moteur active preserve_reasoning par défaut, et le gabarit garde alors la
// réflexion de TOUS les tours passés. Mais sans REASONING_ECHO, Loki ne renvoie
// pas le raisonnement des tours passés : un gabarit qui ne conserve rien de
// lui-même (Qwen3.6) rend alors des blocs de réflexion vides, là où « off » rend
// l'historique des anciens moteurs. À l'inverse, un gabarit qui conserve déjà de
// lui-même (Qwen3.8) changerait de rendu avec « off ». Aucun choix global n'est
// neutre pour tous les modèles : vide = défaut du moteur, inchangé.
//
// Avec REASONING_ECHO=on (llm_reasoning_echo.go), les deux clés se complètent
// sans se recouvrir : l'écho fournit le raisonnement, cette clé décide si le
// gabarit le garde au-delà du tour en cours. « on » donne alors un préfixe
// stable d'un message utilisateur à l'autre, au prix de plus de contexte ; le
// comptage de Loki suit le verdict de la sonde de gabarit (preservesHistory),
// pas cette clé, car un gabarit sans le réglage (Qwen3.5) l'ignore.
func reasoningPreserveArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
v := strings.ToLower(strings.TrimSpace(cfg["REASONING_PRESERVE"]))
if v == "" {
return nil, nil
}
flag := ""
switch v {
case "on":
flag = "--reasoning-preserve"
case "off":
flag = "--no-reasoning-preserve"
default:
return nil, []string{"REASONING_PRESERVE=" + cfg["REASONING_PRESERVE"] + " illisible (on ou off) : ignoré"}
}
switch {
case !strings.Contains(si.Help, "--no-reasoning-preserve"):
return nil, []string{"ce moteur ne connaît pas --reasoning-preserve : REASONING_PRESERVE ignoré"}
case hasAnyFlag(extra, "--reasoning-preserve", "--no-reasoning-preserve"):
return nil, nil
case strings.Contains(strings.Join(flagValues(extra, "--chat-template-kwargs"), " "), "preserve_reasoning"):
return nil, []string{"--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré"}
case si.ArgEnv["LLAMA_ARG_REASONING_PRESERVE"] != "":
return nil, []string{"LLAMA_ARG_REASONING_PRESERVE est posée : REASONING_PRESERVE ignoré"}
case strings.Contains(si.ArgEnv["LLAMA_ARG_CHAT_TEMPLATE_KWARGS"], "preserve_reasoning"):
return nil, []string{"LLAMA_ARG_CHAT_TEMPLATE_KWARGS fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré"}
}
return []string{flag}, nil
}
+247
View File
@@ -0,0 +1,247 @@
package loki
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
const helpCkpt = helpRecent + `-ctxcp, --ctx-checkpoints, --swa-checkpoints N
max number of context checkpoints to create per slot (default: 32)
-cms, --checkpoint-min-step N minimum spacing between context checkpoints in tokens (default: 8192, 0 = no minimum)
--reasoning-preserve, --no-reasoning-preserve
preserve reasoning trace in the full history, not just the last assistant message (default: enabled)
`
// hybridOld : un Qwen3.6 27B hybride de 17 Go sur un moteur officiel b10678,
// machine de 64 Go. État récurrent : 48 couches × (3 × 10240 + 128 × 6144) × 4
// octets ≈ 150 Mio par point de reprise.
func hybridOld() serveSysInfo {
return serveSysInfo{
Help: helpCkpt,
Model: "/models/Qwen3.6-27B.gguf",
GGUF: &GGUFInfo{Arch: "qwen35", Hybrid: true, BlockCount: 64, FullAttnInterval: 4, HeadCountKV: 4,
KeyLen: 256, ValLen: 256, SSMConv: 4, SSMInner: 6144, SSMState: 128, SSMGroups: 16},
ModelBytes: 17 << 30,
RAMMiB: 65536,
RAMAvailMiB: 50000,
ArgEnv: map[string]string{},
EngineBuild: 10678,
}
}
func TestCkptArgs(t *testing.T) {
cms := []string{"--checkpoint-min-step", "2048"}
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
notes int
}{
{name: "hybride, moteur b10678 : espacement resserré d'office", want: cms, notes: 1},
{name: "hybride, moteur b11351 : rien", si: func(s *serveSysInfo) { s.EngineBuild = 11351 }},
{name: "hybride, build pile au correctif : rien", si: func(s *serveSysInfo) { s.EngineBuild = engineMinRecommended }},
{name: "build inconnu (compilé, clone superficiel, fork) : ni avis ni drapeau",
si: func(s *serveSysInfo) { s.EngineBuild = 0 }},
{name: "modèle dense sur moteur ancien : rien",
si: func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "qwen3", BlockCount: 64} }},
{name: "métadonnées illisibles : jamais deviné au nom du fichier",
si: func(s *serveSysInfo) { s.GGUF = nil }},
{name: "arch hybride connue sans clé ssm.*", want: cms, notes: 1,
si: func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "qwen3next"} }},
{name: "CKPT_MIN_STEP=4096 passé tel quel, quel que soit le build",
cfg: map[string]string{"CKPT_MIN_STEP": "4096"}, want: []string{"--checkpoint-min-step", "4096"},
si: func(s *serveSysInfo) { s.EngineBuild = 0 }},
{name: "CKPT_MIN_STEP=0 refusé, défaut du moteur, et on le dit",
cfg: map[string]string{"CKPT_MIN_STEP": "0"}, notes: 1},
{name: "-cms d'EXTRA_ARGS : Loki se tait sur l'espacement",
extra: []string{"-cms", "1024"}, notes: 1},
{name: "--checkpoint-min-step=1024 d'EXTRA_ARGS", cfg: map[string]string{"CKPT_MIN_STEP": "4096"},
extra: []string{"--checkpoint-min-step=1024"}},
{name: "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT posée : pas d'espacement d'office", notes: 1,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] = "4096" }},
{name: "--ctx-checkpoints d'EXTRA_ARGS ne coupe pas l'espacement", extra: []string{"--ctx-checkpoints", "8"},
want: cms, notes: 1},
{name: "aide sans -cms : pas de drapeau, l'avis reste", notes: 1,
si: func(s *serveSysInfo) { s.Help = helpRecent }},
{name: "points de reprise coupés : l'espacement ne sert à rien", cfg: map[string]string{"CTX_CHECKPOINTS": "0"},
want: []string{"--ctx-checkpoints", "0"}, notes: 1},
{name: "MoE mmappé de 82 Go sur 64 Go : conseil, pas d'espacement d'office", notes: 2,
si: func(s *serveSysInfo) { s.ModelBytes = 82 << 30 }},
{name: "RAM inconnue : pas d'espacement d'office", notes: 1,
si: func(s *serveSysInfo) { s.RAMMiB = 0 }},
{name: "RAM libre trop juste pour 32 points", notes: 1,
si: func(s *serveSysInfo) { s.RAMAvailMiB = 8000 }},
// Une liste par slot : 2 × 32 × ~150 Mio tient dans le quart de 50 Go, 4 × non.
{name: "PARALLEL=2 : deux listes, encore dans le quart de la RAM libre", cfg: map[string]string{"PARALLEL": "2"},
want: cms, notes: 1},
{name: "PARALLEL=4 : quatre listes, trop pour la RAM libre", cfg: map[string]string{"PARALLEL": "4"}, notes: 1},
{name: "--parallel 4 d'EXTRA_ARGS l'emporte sur PARALLEL", cfg: map[string]string{"PARALLEL": "1"},
extra: []string{"--parallel", "4"}, notes: 1},
{name: "CTX_CHECKPOINTS=16 passé", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
want: []string{"--ctx-checkpoints", "16", "--checkpoint-min-step", "2048"}, notes: 1},
{name: "CTX_CHECKPOINTS illisible", cfg: map[string]string{"CTX_CHECKPOINTS": "beaucoup"}, want: cms, notes: 2},
{name: "CTX_CHECKPOINTS et LLAMA_ARG_CTX_CHECKPOINTS", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
want: cms, notes: 2,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] = "8" }},
{name: "CTX_CHECKPOINTS et -ctxcp d'EXTRA_ARGS", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
extra: []string{"-ctxcp", "8"}, want: cms, notes: 1},
{name: "moteur sans --ctx-checkpoints : CTX_CHECKPOINTS ignoré, dit", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
notes: 2, si: func(s *serveSysInfo) { s.Help = helpRecent }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := hybridOld()
if c.si != nil {
c.si(&si)
}
got, notes := ckptArgs(c.cfg, c.extra, si)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("ckptArgs = %v, attendu %v", got, c.want)
}
if len(notes) != c.notes {
t.Fatalf("notes = %q, attendu %d", notes, c.notes)
}
})
}
}
// Place dans la ligne complète : avant EXTRA_ARGS, qui garde le dernier mot.
func TestBuildServeArgsCkpt(t *testing.T) {
args, _, notes := buildServeArgs(map[string]string{"NGL": "999"}, []string{"--jinja"}, "/bin/llama-server", hybridOld())
if got := strings.Join(args, " "); !strings.HasSuffix(got, "--checkpoint-min-step 2048 --jinja") {
t.Fatalf("ligne = %s", got)
}
found := false
for _, n := range notes {
found = found || strings.Contains(n, "b10678")
}
if !found {
t.Fatalf("l'avis du moteur ancien manque : %q", notes)
}
}
func TestEngineBuildTrust(t *testing.T) {
cases := []struct {
src string
build int
want int
}{
{"downloaded", 10678, 10678},
{"image", 11351, 11351},
{"prebuilt", 10864, 10864},
{"custom", 10678, 0}, // compilé ou fork : numérotation inconnue
{"downloaded", 0, 0}, // --version illisible
{"image", 1, 0}, // clone superficiel
{"prebuilt", 4999, 0}, // sous le plancher
{"downloaded", 5000, 5000},
}
for _, c := range cases {
if got := engineBuildTrust(c.src, c.build); got != c.want {
t.Errorf("engineBuildTrust(%s, %d) = %d, attendu %d", c.src, c.build, got, c.want)
}
}
if engineBuildNotice(10678) == "" || engineBuildNotice(engineMinRecommended) != "" || engineBuildNotice(0) != "" {
t.Fatal("avis : seulement pour un build connu et antérieur au correctif")
}
}
func TestReasoningPreserveArgs(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
notes int
}{
{name: "clé absente : rien, même sur un moteur qui connaît le drapeau"},
{name: "on", cfg: map[string]string{"REASONING_PRESERVE": "on"}, want: []string{"--reasoning-preserve"}},
{name: "OFF", cfg: map[string]string{"REASONING_PRESERVE": "OFF"}, want: []string{"--no-reasoning-preserve"}},
{name: "illisible", cfg: map[string]string{"REASONING_PRESERVE": "peut-être"}, notes: 1},
{name: "moteur sans le drapeau", cfg: map[string]string{"REASONING_PRESERVE": "off"}, notes: 1,
si: func(s *serveSysInfo) { s.Help = helpRecent }},
{name: "EXTRA_ARGS tranche déjà", cfg: map[string]string{"REASONING_PRESERVE": "off"},
extra: []string{"--reasoning-preserve"}},
{name: "--chat-template-kwargs le fixe", cfg: map[string]string{"REASONING_PRESERVE": "off"},
extra: []string{"--chat-template-kwargs", `{"preserve_reasoning":true}`}, notes: 1},
{name: "LLAMA_ARG_REASONING_PRESERVE posée", cfg: map[string]string{"REASONING_PRESERVE": "off"}, notes: 1,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_REASONING_PRESERVE"] = "1" }},
{name: "LLAMA_ARG_CHAT_TEMPLATE_KWARGS le fixe", cfg: map[string]string{"REASONING_PRESERVE": "on"}, notes: 1,
si: func(s *serveSysInfo) {
s.ArgEnv["LLAMA_ARG_CHAT_TEMPLATE_KWARGS"] = `{"preserve_reasoning":false}`
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := serveSysInfo{Help: helpCkpt, ArgEnv: map[string]string{}}
if c.si != nil {
c.si(&si)
}
got, notes := reasoningPreserveArgs(c.cfg, c.extra, si)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("reasoningPreserveArgs = %v, attendu %v", got, c.want)
}
if len(notes) != c.notes {
t.Fatalf("notes = %q, attendu %d", notes, c.notes)
}
})
}
}
// Après une mise à jour, la version téléchargée qui tournait reste installée :
// c'est le retour arrière sans réseau. Le moteur de l'image, lui, n'a rien à
// garder sous engine/.
func TestEngineKeepAfterUpdate(t *testing.T) {
testHome(t)
for _, tag := range []string{"server-cuda-b10500", "server-cuda-b10678", "server-cuda-b10900"} {
d := filepath.Join(engineDir(), tag)
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "llama-server"), []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
}
nouveau := filepath.Join(engineDir(), "server-cuda-b10900", "llama-server")
ancien := filepath.Join(engineDir(), "server-cuda-b10678", "llama-server")
keep := engineKeepAfterUpdate(nouveau, ancien, nil)
if !reflect.DeepEqual(keep, map[string]bool{"server-cuda-b10900": true, "server-cuda-b10678": true}) {
t.Fatalf("gardés = %v", keep)
}
enginePrune(keep, nil)
if got := engineInstalled(); !reflect.DeepEqual(got, []string{"server-cuda-b10678", "server-cuda-b10900"}) {
t.Fatalf("après ménage : %v", got)
}
if keep := engineKeepAfterUpdate(nouveau, "/app/llama-server", nil); len(keep) != 1 {
t.Fatalf("moteur de l'image : %v", keep)
}
if keep := engineKeepAfterUpdate(nouveau, nouveau, nil); len(keep) != 1 {
t.Fatalf("même version : %v", keep)
}
}
// Nombre de slots : chacun a sa liste de points de reprise.
func TestCkptSlots(t *testing.T) {
cases := []struct {
cfg map[string]string
extra []string
want int
}{
{want: 1},
{cfg: map[string]string{"PARALLEL": "3"}, want: 3},
{cfg: map[string]string{"PARALLEL": "3"}, extra: []string{"-np", "2"}, want: 2},
{extra: []string{"--parallel=6"}, want: 6},
{extra: []string{"--parallel", "-1"}, want: 4},
{cfg: map[string]string{"PARALLEL": "auto"}, want: 4},
}
for _, c := range cases {
if got := ckptSlots(c.cfg, c.extra); got != c.want {
t.Errorf("ckptSlots(%v, %v) = %d, attendu %d", c.cfg, c.extra, got, c.want)
}
}
}
+260
View File
@@ -0,0 +1,260 @@
package loki
import (
"os"
"strconv"
"strings"
)
// Réglages d'expert du moteur local, tous éteints tant que le preset ne les
// demande pas. Aucun ne touche au modèle : ni poids, ni contexte, ni
// échantillonnage — seulement OÙ et COMMENT le même calcul s'exécute.
//
// FIT_TARGET=1024,3072 → --fit-target : marge VRAM laissée libre par carte
// OP_OFFLOAD_MIN_BATCH=N → GGML_OP_OFFLOAD_MIN_BATCH : seuil d'envoi au GPU
// des poids restés en RAM
// CUDA_GRAPH_OPT=on → GGML_CUDA_GRAPH_OPT=1 : branches Q/K/V en parallèle
//
// Une variable déjà présente dans l'environnement (gabarit Docker/Unraid) est
// un choix de l'utilisateur : jamais touchée. Les variables passent par
// l'environnement du seul « loki serve », qui devient llama-server : le reste
// du conteneur ne les voit pas.
// expertEnvKeys : les variables du moteur que ces réglages posent.
var expertEnvKeys = []string{"GGML_CUDA_GRAPH_OPT", "GGML_OP_OFFLOAD_MIN_BATCH"}
// fitArgEnv : les LLAMA_ARG_* qui, sans drapeau, décident de --fit et de sa
// marge. llama.cpp les applique quand la ligne de commande se tait.
var fitArgEnv = []string{"LLAMA_ARG_FIT", "LLAMA_ARG_FIT_TARGET", "LLAMA_ARG_TENSOR_SPLIT"}
// probeExpertEnv relève ce que l'environnement a déjà tranché. Appelée après
// probeServeGPUs, dont elle complète ArgEnv.
func probeExpertEnv(si *serveSysInfo) {
si.UserEnv = map[string]string{}
for _, k := range expertEnvKeys {
if v := os.Getenv(k); v != "" {
si.UserEnv[k] = v
}
}
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range fitArgEnv {
if v := os.Getenv(k); v != "" {
si.ArgEnv[k] = v
}
}
}
// fitTargetMin : la marge par défaut de llama.cpp (common.h), en Mio. En
// dessous, fit range plus serré que ce qu'il sait estimer — le tampon du modèle
// MTP, qu'il ne mesure pas toujours, suffit alors à ramener le « cudaMalloc
// failed: out of memory » au premier long prompt. On remonte, en le disant.
const fitTargetMin = 1024
// fitTargetMaxValues : llama.cpp refuse (et le serveur meurt en boucle) une
// liste aussi longue que son nombre maximal de cartes ; 8 reste loin du bord.
const fitTargetMaxValues = 8
// parseFitTarget valide FIT_TARGET : des entiers en Mio séparés par « , » ou
// « / », une valeur par carte dans l'ordre que voit le moteur (une seule vaut
// pour toutes). Jamais transmis brut : llama.cpp lit chaque valeur avec
// std::stoull, qui lève sur le moindre caractère parasite — moteur mort au
// démarrage, relancé en boucle par systemd ou Docker.
func parseFitTarget(v string) (vals []int, raised bool, ok bool) {
parts := strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == '/' })
if len(parts) == 0 || len(parts) > fitTargetMaxValues ||
strings.Count(v, ",")+strings.Count(v, "/") != len(parts)-1 {
return nil, false, false
}
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" || strings.Trim(p, "0123456789") != "" || len(p) > 7 {
return nil, false, false
}
n, _ := strconv.Atoi(p)
if n < fitTargetMin {
n, raised = fitTargetMin, true
}
vals = append(vals, n)
}
return vals, raised, true
}
// fitTargetArgs traduit FIT_TARGET en --fit-target. Fonction pure.
//
// La marge règle combien de VRAM --fit laisse libre sur chaque carte ; sur deux
// cartes inégales (5060 Ti + 3060), une marge plus large sur la lente pousse
// plus de couches vers la rapide. Placement seulement : mêmes poids, mêmes
// types de cache, même contexte. Rien n'est posé quand :
//
// - le moteur ne connaît pas --fit-target (un drapeau inconnu le tue) ;
// - EXTRA_ARGS ou LLAMA_ARG_FIT_TARGET l'ont déjà fixée ;
// - le contexte n'est pas un nombre positif : avec -c 0, fit a le droit de
// RÉDUIRE le contexte pour tenir, et une marge plus large l'y pousserait
// sans un mot — de l'information perdue pour le modèle ;
// - --fit ne tournera pas (voir fitBlocker) : la clé ne ferait rien, et une
// comparaison A/B mesurerait deux fois le même placement.
func fitTargetArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
raw := strings.TrimSpace(cfg["FIT_TARGET"])
if raw == "" {
return nil, nil
}
ignore := func(why string) (a, n []string) {
return nil, []string{"FIT_TARGET=" + raw + " ignoré : " + why}
}
vals, raised, ok := parseFitTarget(raw)
switch {
case !ok:
return ignore("des Mio par carte, ex. 1024,3072 (8 valeurs au plus)")
case !strings.Contains(si.Help, "--fit-target"):
return ignore("ce moteur ne connaît pas --fit-target")
case hasAnyFlag(extra, "-fitt", "--fit-target"):
return ignore("--fit-target déjà dans EXTRA_ARGS")
case si.ArgEnv["LLAMA_ARG_FIT_TARGET"] != "":
return ignore("LLAMA_ARG_FIT_TARGET déjà posé dans l'environnement")
}
if ctx, ok := ctxFixed(cfg, extra); !ok {
return ignore("contexte « " + ctx + " » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré")
}
if why := fitBlocker(cfg, extra, si); why != "" {
return ignore("--fit inactif (" + why + "), la marge ne servirait à rien")
}
s := make([]string, len(vals))
for i, n := range vals {
s[i] = strconv.Itoa(n)
}
v := strings.Join(s, ",")
note := "--fit-target " + v + " (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — " +
"vérifie « offloaded N/N layers » dans le journal"
if raised {
note += " ; marges sous 1024 Mio remontées à 1024"
}
return []string{"--fit-target", v}, []string{note}
}
// fitBlocker dit pourquoi llama.cpp ne lancera PAS --fit (common/fit.cpp :
// « already set by user, abort »), ou vide s'il le lancera. Les couches GPU
// sont lues comme buildServeArgs les écrira : EXTRA_ARGS d'abord, sinon la
// traduction de NGL par nglArgs (999 y devient auto), sinon — NGL=auto, aucun
// drapeau — la variable du moteur.
func fitBlocker(cfg map[string]string, extra []string, si serveSysInfo) string {
fit := flagValue(extra, "-fit", "--fit")
if fit == "" {
fit = si.ArgEnv["LLAMA_ARG_FIT"]
}
switch strings.ToLower(strings.TrimSpace(fit)) {
case "off", "disabled", "false", "0":
return "--fit off"
}
ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
if ngl == "" {
if a, _ := nglArgs(cfg["NGL"], helpFitsLayersItself(si.Help)); len(a) == 2 {
ngl = a[1]
} else {
ngl = si.ArgEnv["LLAMA_ARG_N_GPU_LAYERS"]
}
}
// -1 est la valeur par défaut du moteur, celle qu'« auto » écrit : fit ne
// la prend pas pour un choix de l'utilisateur.
if ngl = strings.TrimSpace(ngl); ngl != "" && ngl != "-1" && !strings.EqualFold(ngl, "auto") {
return "couches GPU fixées à " + ngl
}
if hasAnyFlag(extra, "-ts", "--tensor-split") || si.ArgEnv["LLAMA_ARG_TENSOR_SPLIT"] != "" {
return "--tensor-split"
}
if r := tensorOverride(extra, si.ArgEnv); r != "" {
return r
}
sm := flagValue(extra, "-sm", "--split-mode")
if sm == "" {
sm = si.ArgEnv["LLAMA_ARG_SPLIT_MODE"]
}
// row ET tensor : fit abandonne sur l'une comme sur l'autre (« not
// implemented », common/fit.cpp).
if sm = strings.ToLower(strings.TrimSpace(sm)); sm == "row" || sm == "tensor" {
return "-sm " + sm
}
return ""
}
// opOffloadMinBatchEnv traduit OP_OFFLOAD_MIN_BATCH en GGML_OP_OFFLOAD_MIN_BATCH.
// Fonction pure.
//
// Quand des poids restent en RAM (experts MoE sur CPU), llama.cpp recopie vers
// le GPU ceux dont un lot a besoin dès qu'il compte au moins 32 jetons ; en
// dessous, il calcule sur CPU. Monter le seuil garde sur CPU les lots plus
// courts : utile seulement si une spéculation ngram propose des brouillons de
// 32 jetons ou plus (les lots de vérification MTP, quelques jetons, restent
// déjà sous 32). Le prix : un tour d'outil de 32 à N-1 jetons se calcule lui
// aussi sur CPU. N se trouve à la mesure, pas d'office. Même distribution ;
// les logits ne sont pas identiques au bit près, comme déjà entre décodage CPU
// et prefill GPU.
func opOffloadMinBatchEnv(cfg map[string]string, si serveSysInfo) (val string, notes []string) {
raw := strings.TrimSpace(cfg["OP_OFFLOAD_MIN_BATCH"])
if raw == "" || si.UserEnv["GGML_OP_OFFLOAD_MIN_BATCH"] != "" {
return "", nil
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > 1<<20 {
return "", []string{"OP_OFFLOAD_MIN_BATCH=" + raw + " ignoré : un nombre de jetons, ex. 128"}
}
v := strconv.Itoa(n)
return v, []string{"GGML_OP_OFFLOAD_MIN_BATCH=" + v + " (OP_OFFLOAD_MIN_BATCH) : lots de moins de " + v +
" jetons calculés sur CPU pour les poids restés en RAM"}
}
// graphOptEnv traduit CUDA_GRAPH_OPT=on en GGML_CUDA_GRAPH_OPT=1. Fonction pure.
//
// Au décodage, llama.cpp lance alors les trois branches Q, K et V de chaque
// couche sur des flux CUDA concurrents. Chaque opération garde son noyau et ses
// entrées : sortie identique au jeton près en amont (PR #28198, perplexité
// inchangée). Gain modeste — quelques % sur un MoE tout GPU, rien sur un dense,
// dilué quand les experts tournent sur CPU ou que MTP vérifie par lots. Sans
// effet avant #28198 en multi-GPU ou si les graphes CUDA sont coupés.
//
// Expérimental : sur une architecture qu'il n'a pas vue, le repérage des
// branches peut s'arrêter sur un GGML_ASSERT, et le moteur redémarre en boucle.
// Une variable ne se détecte pas dans l'aide du moteur : on le dit au
// lancement, pour que « CUDA_GRAPH_OPT=off puis redémarrer » vienne à l'esprit.
//
// Refusé quand Q, K et V d'une même couche pourraient finir sur deux cartes
// (-sm row/tensor, -ot visant l'attention) : #28198 en fait une condition
// d'exactitude.
func graphOptEnv(cfg map[string]string, extra []string, si serveSysInfo) (val string, notes []string) {
raw := strings.TrimSpace(cfg["CUDA_GRAPH_OPT"])
switch strings.ToLower(raw) {
case "", "off":
return "", nil
case "on":
default:
return "", []string{"CUDA_GRAPH_OPT=" + raw + " ignoré (on ou off)"}
}
if si.UserEnv["GGML_CUDA_GRAPH_OPT"] != "" {
return "", nil
}
if why := qkvSplitRisk(extra, si.ArgEnv); why != "" {
return "", []string{"CUDA_GRAPH_OPT ignoré : " + why + " peut séparer Q, K et V d'une couche entre cartes"}
}
return "1", []string{"GGML_CUDA_GRAPH_OPT=1 (CUDA_GRAPH_OPT, expérimental) — si le moteur s'arrête sur un " +
"GGML_ASSERT, CUDA_GRAPH_OPT=off puis redémarre"}
}
// qkvSplitRisk : un placement qui pourrait répartir les tenseurs d'attention
// d'une même couche sur plusieurs appareils. La découpe par couches (défaut,
// --tensor-split compris) garde chaque couche entière : rien à craindre.
func qkvSplitRisk(extra []string, argEnv map[string]string) string {
sm := flagValue(extra, "-sm", "--split-mode")
if sm == "" {
sm = argEnv["LLAMA_ARG_SPLIT_MODE"]
}
if sm = strings.ToLower(strings.TrimSpace(sm)); sm == "row" || sm == "tensor" {
return "-sm " + sm
}
for _, v := range append(flagValues(extra, "-ot", "--override-tensor"), argEnv["LLAMA_ARG_OVERRIDE_TENSOR"]) {
if strings.Contains(strings.ToLower(v), "attn") {
return "-ot sur l'attention"
}
}
return ""
}
+218
View File
@@ -0,0 +1,218 @@
package loki
import (
"reflect"
"testing"
)
// helpFit : un moteur récent qui connaît --fit et sa marge.
const helpFit = helpRecent + `-fit, --fit [on|off] whether to adjust unset arguments to fit in device memory
-fitt, --fit-target MiB0,MiB1,... target margin per device for --fit (default: 1024)
`
// TestFitTargetArgs : --fit-target n'est passé que validé, sur un moteur qui le
// connaît, avec un contexte fixé, et quand --fit tournera vraiment.
func TestFitTargetArgs(t *testing.T) {
fit := serveSysInfo{Help: helpFit}
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want []string
wantNotes int
}{
{name: "clé absente : rien, sans un mot",
cfg: map[string]string{}, si: fit},
{name: "deux marges : passées, et on le dit",
cfg: map[string]string{"FIT_TARGET": "1024,3072"}, si: fit,
want: []string{"--fit-target", "1024,3072"}, wantNotes: 1},
{name: "séparateur / : normalisé en virgules",
cfg: map[string]string{"FIT_TARGET": "2048/1536"}, si: fit,
want: []string{"--fit-target", "2048,1536"}, wantNotes: 1},
{name: "une seule valeur : vaut pour toutes les cartes",
cfg: map[string]string{"FIT_TARGET": " 2048 "}, si: fit,
want: []string{"--fit-target", "2048"}, wantNotes: 1},
{name: "marge sous le défaut : remontée à 1024",
cfg: map[string]string{"FIT_TARGET": "256,3072"}, si: fit,
want: []string{"--fit-target", "1024,3072"}, wantNotes: 1},
{name: "valeur non numérique : jamais transmise (stoull ferait mourir le moteur)",
cfg: map[string]string{"FIT_TARGET": "1G,2G"}, si: fit, wantNotes: 1},
{name: "valeur négative : refusée",
cfg: map[string]string{"FIT_TARGET": "-1024"}, si: fit, wantNotes: 1},
{name: "séparateur doublé : refusé",
cfg: map[string]string{"FIT_TARGET": "1024,,2048"}, si: fit, wantNotes: 1},
{name: "trop de valeurs : refusé",
cfg: map[string]string{"FIT_TARGET": "1,2,3,4,5,6,7,8,9"}, si: fit, wantNotes: 1},
{name: "moteur sans --fit-target : rien, le drapeau le tuerait",
cfg: map[string]string{"FIT_TARGET": "1024,3072"}, si: serveSysInfo{Help: helpRecent}, wantNotes: 1},
{name: "-fitt déjà dans EXTRA_ARGS : l'utilisateur a tranché",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-fitt 2048"}, si: fit, wantNotes: 1},
{name: "LLAMA_ARG_FIT_TARGET dans l'environnement : intouché",
cfg: map[string]string{"FIT_TARGET": "1024"},
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_FIT_TARGET": "4096"}}, wantNotes: 1},
{name: "CTX=0 : fit pourrait réduire le contexte, refusé",
cfg: map[string]string{"FIT_TARGET": "1024,3072", "CTX": "0"}, si: fit, wantNotes: 1},
{name: "-c 0 dans EXTRA_ARGS l'emporte sur CTX : refusé",
cfg: map[string]string{"FIT_TARGET": "1024", "CTX": "65536", "EXTRA_ARGS": "-c 0"}, si: fit, wantNotes: 1},
{name: "CTX absent : Loki passe -c 32768, accepté",
cfg: map[string]string{"FIT_TARGET": "1024"}, si: fit,
want: []string{"--fit-target", "1024"}, wantNotes: 1},
{name: "NGL=999 : devient -ngl auto, fit tourne",
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "999"}, si: fit,
want: []string{"--fit-target", "1024"}, wantNotes: 1},
{name: "NGL chiffré : fit abandonne, rien",
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "40"}, si: fit, wantNotes: 1},
{name: "NGL=all : fit abandonne, rien",
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "all"}, si: fit, wantNotes: 1},
{name: "-ngl auto dans EXTRA_ARGS : fit tourne",
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "40", "EXTRA_ARGS": "-ngl auto"}, si: fit,
want: []string{"--fit-target", "1024"}, wantNotes: 1},
{name: "NGL=auto et LLAMA_ARG_N_GPU_LAYERS=30 : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "auto"},
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_N_GPU_LAYERS": "30"}}, wantNotes: 1},
{name: "--tensor-split : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "--tensor-split 0.965,0.035"}, si: fit, wantNotes: 1},
{name: "preset MoE (-ot, --n-cpu-moe) : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": `-ot "per_layer_token_embd=CPU" --n-cpu-moe 40`},
si: fit, wantNotes: 1},
{name: "--cpu-moe : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-cmoe"}, si: fit, wantNotes: 1},
{name: "--fit off : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "--fit off"}, si: fit, wantNotes: 1},
{name: "LLAMA_ARG_FIT=off : rien",
cfg: map[string]string{"FIT_TARGET": "1024"},
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_FIT": "off"}}, wantNotes: 1},
{name: "-sm row : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-sm row"}, si: fit, wantNotes: 1},
{name: "-sm tensor : fit abandonne aussi, rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "--split-mode tensor"}, si: fit, wantNotes: 1},
{name: "LLAMA_ARG_SPLIT_MODE=tensor : rien",
cfg: map[string]string{"FIT_TARGET": "1024"},
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_SPLIT_MODE": "tensor"}}, wantNotes: 1},
{name: "-sm layer : fit tourne",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-sm layer"}, si: fit,
want: []string{"--fit-target", "1024"}, wantNotes: 1},
{name: "-ngl -1 (le défaut du moteur) : fit tourne",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-ngl -1"}, si: fit,
want: []string{"--fit-target", "1024"}, wantNotes: 1},
{name: "--device choisi : le placement reste à fit, accepté",
cfg: map[string]string{"FIT_TARGET": "1024,3072", "EXTRA_ARGS": "--device CUDA1,CUDA0"}, si: fit,
want: []string{"--fit-target", "1024,3072"}, wantNotes: 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, notes := fitTargetArgs(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("args = %q, attendu %q", got, c.want)
}
if len(notes) != c.wantNotes {
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
}
})
}
}
// TestGraphOptEnv : GGML_CUDA_GRAPH_OPT=1 seulement sur demande, jamais
// par-dessus l'environnement, jamais quand Q/K/V pourraient changer de carte.
func TestGraphOptEnv(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want string
wantNotes int
}{
{name: "clé absente : rien", cfg: map[string]string{}},
{name: "off : rien", cfg: map[string]string{"CUDA_GRAPH_OPT": "off"}},
{name: "on : posé, et on le dit", cfg: map[string]string{"CUDA_GRAPH_OPT": "on"}, want: "1", wantNotes: 1},
{name: "ON (casse libre) : posé", cfg: map[string]string{"CUDA_GRAPH_OPT": "ON"}, want: "1", wantNotes: 1},
{name: "valeur inconnue : ignorée, dite", cfg: map[string]string{"CUDA_GRAPH_OPT": "2"}, wantNotes: 1},
{name: "variable déjà posée (même à 0) : intouchée",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on"},
si: serveSysInfo{UserEnv: map[string]string{"GGML_CUDA_GRAPH_OPT": "0"}}},
{name: "découpe par couches + --tensor-split : couches entières, posé",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": "--device CUDA1,CUDA0 -ts 0.6,0.4"},
want: "1", wantNotes: 1},
{name: "-sm row : refusé",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": "-sm row"}, wantNotes: 1},
{name: "LLAMA_ARG_SPLIT_MODE=tensor : refusé",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on"},
si: serveSysInfo{ArgEnv: map[string]string{"LLAMA_ARG_SPLIT_MODE": "tensor"}}, wantNotes: 1},
{name: "-ot sur l'attention : refusé",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": `-ot "blk\.1\.attn_k.*=CUDA1"`}, wantNotes: 1},
{name: "-ot des experts seulement : posé",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": `-ot "ffn_.*_exps=CPU" --n-cpu-moe 40`},
want: "1", wantNotes: 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, notes := graphOptEnv(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
if got != c.want {
t.Fatalf("valeur = %q, attendu %q", got, c.want)
}
if len(notes) != c.wantNotes {
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
}
})
}
}
// TestOpOffloadMinBatchEnv : la variable n'existe que si la clé est posée et
// valide, et ne remplace jamais celle de l'environnement.
func TestOpOffloadMinBatchEnv(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want string
wantNotes int
}{
{name: "clé absente : rien", cfg: map[string]string{}},
{name: "128 : posé", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "128"}, want: "128", wantNotes: 1},
{name: "espaces autour : posé", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": " 64 "}, want: "64", wantNotes: 1},
{name: "0 : ignoré, dit", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "0"}, wantNotes: 1},
{name: "texte : ignoré, dit", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "beaucoup"}, wantNotes: 1},
{name: "variable déjà posée : intouchée",
cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "128"},
si: serveSysInfo{UserEnv: map[string]string{"GGML_OP_OFFLOAD_MIN_BATCH": "32"}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, notes := opOffloadMinBatchEnv(c.cfg, c.si)
if got != c.want {
t.Fatalf("valeur = %q, attendu %q", got, c.want)
}
if len(notes) != c.wantNotes {
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
}
})
}
}
// Les trois clés absentes, la ligne de commande et l'environnement ne bougent
// pas ; posées, --fit-target arrive avant EXTRA_ARGS et les variables vont
// dans env, nulle part ailleurs.
func TestBuildServeArgsExpertToggles(t *testing.T) {
const bin, model = "/opt/llama/llama-server", "/models/Qwen3.6-27B-Q4_K_M.gguf"
si := serveSysInfo{Help: helpFit, Model: model}
cfg := map[string]string{"CTX": "65536", "EXTRA_ARGS": "--device CUDA0"}
base, baseEnv, baseNotes := buildServeArgs(cfg, splitArgs(cfg["EXTRA_ARGS"]), bin, si)
if len(baseEnv) != 0 || len(baseNotes) != 0 {
t.Fatalf("sans les clés : env = %v, notes = %q", baseEnv, baseNotes)
}
on := map[string]string{"CTX": "65536", "EXTRA_ARGS": "--device CUDA0",
"FIT_TARGET": "1024,3072", "OP_OFFLOAD_MIN_BATCH": "128", "CUDA_GRAPH_OPT": "on"}
args, env, notes := buildServeArgs(on, splitArgs(on["EXTRA_ARGS"]), bin, si)
n := len(base)
want := append(append(append([]string{}, base[:n-2]...), "--fit-target", "1024,3072"), base[n-2:]...)
if !reflect.DeepEqual(args, want) {
t.Fatalf("args\n got %q\nwant %q", args, want)
}
wantEnv := map[string]string{"GGML_CUDA_GRAPH_OPT": "1", "GGML_OP_OFFLOAD_MIN_BATCH": "128"}
if !reflect.DeepEqual(env, wantEnv) {
t.Fatalf("env = %v, want %v", env, wantEnv)
}
if len(notes) != 3 {
t.Fatalf("notes = %q", notes)
}
}
+217
View File
@@ -0,0 +1,217 @@
package loki
import (
"fmt"
"os"
"strconv"
"strings"
)
// Garde-fous de fidélité. Loki ne choisit JAMAIS à la place de l'utilisateur un
// réglage qui change ce que le modèle calcule : pas de cache KV quantifié posé
// d'office, pas de réutilisation approximative du cache, pas de contexte qui
// glisse en jetant des jetons. Ces réglages restent possibles — ce sont des
// compromis légitimes quand la VRAM manque — mais ils doivent se VOIR : un
// preset qui tourne en q8_0 depuis EXTRA_ARGS pendant que l'interface affiche
// « f16 » est exactement l'altération silencieuse qu'on refuse.
//
// Tout ici est pur : buildServeArgs en tire des notes, cmdServe les écrit.
// fidelityArgEnv : les LLAMA_ARG_* qui, sans drapeau, posent un cache
// quantifié, un cache approché ou la vision. llama.cpp les applique AVANT la
// ligne de commande : un -ctk de KV_TYPE ou d'EXTRA_ARGS les écrase, mais seuls
// ils décident. LLAMA_ARG_NO_CONTEXT_SHIFT est celle des moteurs anciens.
var fidelityArgEnv = []string{"LLAMA_ARG_CACHE_TYPE_K", "LLAMA_ARG_CACHE_TYPE_V",
"LLAMA_ARG_CONTEXT_SHIFT", "LLAMA_ARG_NO_CONTEXT_SHIFT", "LLAMA_ARG_CACHE_REUSE",
"LLAMA_ARG_MMPROJ", "LLAMA_ARG_MMPROJ_URL", "LLAMA_ARG_SPEC_SYNTH_LEN"}
// probeFidelityEnv complète ArgEnv avec fidelityArgEnv. Appelée après
// probeServeGPUs, qui crée ArgEnv.
func probeFidelityEnv(si *serveSysInfo) {
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range fidelityArgEnv {
if v, ok := os.LookupEnv(k); ok && v != "" {
si.ArgEnv[k] = v
}
}
}
// effectiveKVTypes donne les types de cache K et V que le moteur utilisera
// VRAIMENT. Trois sources, de la plus faible à la plus forte : les variables
// LLAMA_ARG_CACHE_TYPE_K/V (lues avant la ligne de commande), le preset
// (serveKVTypes, traduit en -ctk/-ctv), puis EXTRA_ARGS, qui ferme la ligne :
// llama-server retient la dernière occurrence d'un drapeau, un -ctk q8_0 écrit
// à la main l'emporte donc sur KV_TYPE. src nomme la source la plus forte qui a
// tranché l'un des deux (vide : aucune). Vide = défaut du moteur (f16).
func effectiveKVTypes(cfg map[string]string, extra []string, argEnv map[string]string) (k, v, src string) {
k, v = argEnv["LLAMA_ARG_CACHE_TYPE_K"], argEnv["LLAMA_ARG_CACHE_TYPE_V"]
if k != "" || v != "" {
src = "défini par LLAMA_ARG_CACHE_TYPE_K/V"
}
if ck, cv := serveKVTypes(cfg); ck != "" || cv != "" {
src = "KV_TYPE"
if ck != "" {
k = ck
}
if cv != "" {
v = cv
}
}
if s := flagValue(extra, "-ctk", "--cache-type-k"); s != "" {
k, src = s, "défini par EXTRA_ARGS"
}
if s := flagValue(extra, "-ctv", "--cache-type-v"); s != "" {
v, src = s, "défini par EXTRA_ARGS"
}
return k, v, src
}
// kvFidelity classe un type de cache par rapport au f16 de référence. Rang 0 =
// mêmes calculs (f16, ou f32 qui ne perd rien) ; plus le rang monte, plus les
// sorties s'en écartent. Un type inconnu (sorti après cette version) est
// supposé altérer : on préfère un avertissement de trop à un silence.
func kvFidelity(t string) (rank int, label string) {
switch strings.ToLower(strings.TrimSpace(t)) {
case "", "f16", "f32":
return 0, ""
case "bf16":
return 1, "numérique différente de f16 (mantisse plus courte)"
case "q8_0", "q5_0", "q5_1":
return 2, "modifie légèrement les sorties par rapport à f16"
case "q4_0", "q4_1", "iq4_nl":
return 3, "perte mesurable par rapport à f16"
default:
return 2, "modifie les sorties par rapport à f16"
}
}
// kvFidelityNote dit, quand le cache n'est pas en f16, ce que ce choix coûte en
// fidélité et d'où il vient. Le choix reste celui du preset : Loki ne le change
// jamais, ni dans un sens ni dans l'autre. Pas d'estimation de la VRAM qu'un
// retour en f16 demanderait : sans les métadonnées du GGUF, ce serait un chiffre
// inventé. En revanche, si le placement est figé à la main (-ot, --n-cpu-moe N>0,
// en drapeau ou en variable : tensorOverride), --fit ne tourne pas et ne
// rattrapera pas ce surplus — mieux vaut le savoir avant de basculer et de
// finir en « out of memory ».
func kvFidelityNote(k, v, src string, extra []string, argEnv map[string]string) string {
rk, lk := kvFidelity(k)
rv, lv := kvFidelity(v)
if rk == 0 && rv == 0 {
return ""
}
label := lk
if rv > rk {
label = lv
}
note := fmt.Sprintf("cache KV %s/%s (%s) : %s — choix de l'utilisateur, laissé tel quel.",
orF16(k), orF16(v), src, label)
if tensorOverride(extra, argEnv) != "" {
note += " Repasser en f16 demande plus de VRAM, et le placement fixé par -ot/--n-cpu-moe " +
"empêche --fit de compenser : il faudrait sans doute relever --n-cpu-moe."
}
return note
}
func orF16(t string) string {
if t == "" {
return "f16"
}
return t
}
// lossyCacheNotes avertit des réglages de cache qui changent ce que voit le
// modèle. Seuls deux le font :
//
// - le glissement de contexte : contexte plein → le moteur JETTE des jetons
// anciens et continue, le modèle perd une partie de la conversation sans le
// savoir ;
// - --cache-reuse N (N > 0) : recolle des morceaux de cache calculés sous un
// AUTRE préfixe, simplement décalés — le résultat n'est plus celui d'un
// calcul complet. --cache-reuse 0 le désactive : rien à dire.
//
// --swa-full n'en fait PAS partie : il garde le cache complet des couches à
// fenêtre glissante, ce qui rend la réutilisation du préfixe exacte (au prix de
// VRAM), et ne fait rien sur un modèle sans SWA. Correspondance exacte des noms
// (flagValue) : --no-context-shift ne déclenche rien, et la dernière occurrence
// gagne comme dans llama-server. Les variables LLAMA_ARG_* comptent quand la
// ligne de commande se tait.
//
// Avec la vision chargée (MMPROJ, --mmproj d'EXTRA_ARGS ou LLAMA_ARG_MMPROJ),
// llama.cpp désactive lui-même les deux (non pris en charge en multimodal) : on
// le dit plutôt que de crier au loup. Les modèles hybrides ou récurrents les
// ignorent aussi, mais sans métadonnées du GGUF on ne sait pas les reconnaître
// ici : on se tait sur ce point plutôt que deviner.
func lossyCacheNotes(extra []string, si serveSysInfo) []string {
var notes []string
ignored := ""
if si.MMProj != "" || hasAnyFlag(extra, "-mm", "--mmproj", "-mmu", "--mmproj-url") ||
si.ArgEnv["LLAMA_ARG_MMPROJ"] != "" || si.ArgEnv["LLAMA_ARG_MMPROJ_URL"] != "" {
ignored = " (vision chargée : llama.cpp l'ignore de toute façon)"
}
switch on, src := contextShift(extra, si.ArgEnv, si.Help); {
case on && src == "":
notes = append(notes, "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte "+
"plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir"+
ignored+". --no-context-shift dans EXTRA_ARGS l'en empêche.")
case on:
notes = append(notes, "avertissement : --context-shift ("+src+") — contexte plein, le moteur jette "+
"des jetons anciens et le modèle perd une partie de la conversation sans le savoir"+ignored+".")
}
s, src := flagValue(extra, "--cache-reuse"), "EXTRA_ARGS"
if s == "" {
s, src = si.ArgEnv["LLAMA_ARG_CACHE_REUSE"], "LLAMA_ARG_CACHE_REUSE"
}
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && n > 0 {
notes = append(notes, fmt.Sprintf("avertissement : --cache-reuse %d (%s) — réutilise des morceaux "+
"de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul "+
"complet%s.", n, src, ignored))
}
// Acceptation SYNTHÉTIQUE (« benchmarking only ») : le moteur garde des
// jetons du brouillon au hasard, sans les comparer à ce qu'aurait tiré le
// modèle. Loki ne les pose jamais ; écrits à la main, on le crie.
if f := hasFlagPrefix(extra, "--spec-synth-"); f != "" || si.ArgEnv["LLAMA_ARG_SPEC_SYNTH_LEN"] != "" {
if f == "" {
f = "LLAMA_ARG_SPEC_SYNTH_LEN"
}
notes = append(notes, "avertissement : "+f+" — le moteur accepte des jetons du brouillon AU HASARD, sans "+
"vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le.")
}
return notes
}
// contextShift dit si le moteur glissera le contexte, et qui l'a décidé (vide :
// son propre défaut). --context-shift et --no-context-shift peuvent cohabiter
// dans EXTRA_ARGS (preset copié, puis corrigé) ; comme llama-server, la
// dernière occurrence décide. Sans drapeau, la variable du moteur, puis son
// défaut — qui a changé : les llama-server d'avant --context-shift (mi-2025) ne
// connaissent que --no-context-shift et GLISSENT par défaut. Aide vide (moteur
// inconnu) : on ne suppose rien.
func contextShift(extra []string, argEnv map[string]string, help string) (on bool, src string) {
set := false
for _, a := range extra {
name, _, _ := strings.Cut(a, "=")
switch name {
case "--context-shift":
on, set = true, true
case "--no-context-shift":
on, set = false, true
}
}
if set {
return on, "EXTRA_ARGS"
}
if strings.Contains(help, "--no-context-shift") && !strings.Contains(help, "--context-shift") {
// Moteur ancien : seul LLAMA_ARG_NO_CONTEXT_SHIFT (lu comme un booléen
// vrai) l'arrête ; LLAMA_ARG_CONTEXT_SHIFT lui est inconnue.
if envTruthy(argEnv["LLAMA_ARG_NO_CONTEXT_SHIFT"]) {
return false, "LLAMA_ARG_NO_CONTEXT_SHIFT"
}
return true, ""
}
if envTruthy(argEnv["LLAMA_ARG_CONTEXT_SHIFT"]) {
return true, "LLAMA_ARG_CONTEXT_SHIFT"
}
return false, ""
}
@@ -0,0 +1,307 @@
package loki
import (
"context"
"encoding/json"
"go/ast"
"go/parser"
"go/token"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"reflect"
"strconv"
"strings"
"sync"
"testing"
)
func TestEffectiveKVTypes(t *testing.T) {
const ea, kv = "défini par EXTRA_ARGS", "KV_TYPE"
const env = "défini par LLAMA_ARG_CACHE_TYPE_K/V"
for _, c := range []struct {
name string
cfg map[string]string
extra string
argEnv map[string]string
k, v string
src string
}{
{"rien : défaut moteur", map[string]string{}, "", nil, "", "", ""},
{"KV_TYPE seul", map[string]string{"KV_TYPE": "q8_0"}, "", nil, "q8_0", "q8_0", kv},
{"EXTRA_ARGS l'emporte sur KV_TYPE", map[string]string{"KV_TYPE": "f16"},
"--cache-type-k q8_0 --cache-type-v q8_0", nil, "q8_0", "q8_0", ea},
{"formes courtes, la dernière gagne", map[string]string{},
"-ctk q4_0 -ctv q4_0 -ctk q8_0", nil, "q8_0", "q4_0", ea},
{"forme --flag=valeur", map[string]string{}, "--cache-type-v=q8_0", nil, "", "q8_0", ea},
{"K seul dans EXTRA_ARGS, V du preset", map[string]string{"KV_TYPE_V": "q4_0"},
"-ctk q8_0", nil, "q8_0", "q4_0", ea},
// Les variables passent AVANT la ligne de commande : seules, elles
// décident ; un -ctk venu de KV_TYPE les écrase.
{"variables seules", map[string]string{}, "",
map[string]string{"LLAMA_ARG_CACHE_TYPE_K": "q8_0", "LLAMA_ARG_CACHE_TYPE_V": "q8_0"}, "q8_0", "q8_0", env},
{"KV_TYPE_K écrase la variable K, la variable V reste", map[string]string{"KV_TYPE_K": "f16"}, "",
map[string]string{"LLAMA_ARG_CACHE_TYPE_K": "q4_0", "LLAMA_ARG_CACHE_TYPE_V": "q8_0"}, "f16", "q8_0", kv},
} {
t.Run(c.name, func(t *testing.T) {
k, v, src := effectiveKVTypes(c.cfg, splitArgs(c.extra), c.argEnv)
if k != c.k || v != c.v || src != c.src {
t.Fatalf("got %q/%q src=%q, want %q/%q src=%q", k, v, src, c.k, c.v, c.src)
}
})
}
}
func TestKVFidelityNote(t *testing.T) {
for _, c := range []struct {
name string
k, v string
src string
extra string
argEnv map[string]string
want []string // fragments attendus ; nil = aucune note
fit bool // la note doit parler de --fit (placement figé)
}{
{"défaut moteur : rien à dire", "", "", "", "", nil, nil, false},
{"f16 explicite : rien à dire", "f16", "f16", "KV_TYPE", "", nil, nil, false},
{"f32 ne perd rien", "f32", "f32", "KV_TYPE", "", nil, nil, false},
{"q8_0 : léger écart", "q8_0", "q8_0", "KV_TYPE", "", nil,
[]string{"q8_0/q8_0", "KV_TYPE", "modifie légèrement"}, false},
{"q4_0 : perte mesurable", "q4_0", "q4_0", "KV_TYPE", "", nil, []string{"perte mesurable"}, false},
{"mixte : le pire des deux", "q8_0", "q4_0", "KV_TYPE", "", nil, []string{"perte mesurable"}, false},
{"bf16 : numérique différente", "bf16", "bf16", "KV_TYPE", "", nil, []string{"mantisse"}, false},
{"V seul quantifié", "", "q8_0", "KV_TYPE", "", nil, []string{"f16/q8_0"}, false},
{"type inconnu : supposé altérer", "q3_k", "q3_k", "KV_TYPE", "", nil, []string{"modifie les sorties"}, false},
{"venu d'EXTRA_ARGS, placement figé : --fit ne compense pas", "q8_0", "q8_0", "défini par EXTRA_ARGS",
"-ot per_layer_token_embd.weight=CPU --n-cpu-moe 40 --cache-type-k q8_0", nil,
[]string{"défini par EXTRA_ARGS", "relever --n-cpu-moe"}, true},
// --n-cpu-moe 0 ne place rien : --fit tourne, rien à en dire.
{"--n-cpu-moe 0 : placement libre", "q8_0", "q8_0", "défini par EXTRA_ARGS",
"--n-cpu-moe 0 -ctk q8_0 -ctv q8_0", nil, []string{"q8_0/q8_0"}, false},
{"experts sur CPU par variable : placement figé aussi", "q8_0", "q8_0", "KV_TYPE", "",
map[string]string{"LLAMA_ARG_N_CPU_MOE": "30"}, []string{"relever --n-cpu-moe"}, true},
} {
t.Run(c.name, func(t *testing.T) {
got := kvFidelityNote(c.k, c.v, c.src, splitArgs(c.extra), c.argEnv)
if c.want == nil {
if got != "" {
t.Fatalf("note inattendue : %q", got)
}
return
}
for _, w := range c.want {
if !strings.Contains(got, w) {
t.Fatalf("note %q sans %q", got, w)
}
}
if strings.Contains(got, "--fit") != c.fit {
t.Fatalf("note %q : mention de --fit attendue = %v", got, c.fit)
}
})
}
}
func TestLossyCacheNotes(t *testing.T) {
// Aides des deux générations de llama-server : l'ancienne ne connaît que
// --no-context-shift et glisse par défaut, la récente ne glisse pas.
const helpShiftOld = "--no-context-shift disables context shift on infinite text generation (default: disabled)"
const helpShiftNew = "--context-shift, --no-context-shift whether to use context shift on infinite text generation (default: disabled)"
envOf := func(kv ...string) map[string]string {
m := map[string]string{}
for i := 0; i+1 < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
}
for _, c := range []struct {
name string
extra string
si serveSysInfo
want []string // un fragment par note attendue, dans l'ordre
}{
{"rien", "", serveSysInfo{}, nil},
{"--context-shift", "--context-shift", serveSysInfo{}, []string{"--context-shift (EXTRA_ARGS)"}},
{"--no-context-shift ne déclenche rien", "--no-context-shift", serveSysInfo{}, nil},
{"la dernière occurrence décide", "--context-shift --no-context-shift", serveSysInfo{}, nil},
{"--cache-reuse 256", "--cache-reuse 256", serveSysInfo{}, []string{"--cache-reuse 256 (EXTRA_ARGS)"}},
{"--cache-reuse=256", "--cache-reuse=256", serveSysInfo{}, []string{"--cache-reuse 256"}},
{"--cache-reuse 0 : désactivé", "--cache-reuse 0", serveSysInfo{}, nil},
{"--swa-full est sans perte", "--swa-full", serveSysInfo{}, nil},
{"les deux", "--context-shift --cache-reuse 64", serveSysInfo{}, []string{"jette", "--cache-reuse 64"}},
{"vision (MMPROJ) : llama.cpp les ignore, on le dit", "--context-shift --cache-reuse 64",
serveSysInfo{MMProj: "/m/mmproj.gguf"}, []string{"l'ignore", "l'ignore"}},
{"vision par --mmproj d'EXTRA_ARGS : pareil", "--mmproj mmproj-F16.gguf --cache-reuse 64",
serveSysInfo{}, []string{"l'ignore"}},
// Les variables du moteur comptent quand la ligne de commande se tait.
{"LLAMA_ARG_CACHE_REUSE", "", serveSysInfo{ArgEnv: envOf("LLAMA_ARG_CACHE_REUSE", "128")},
[]string{"--cache-reuse 128 (LLAMA_ARG_CACHE_REUSE)"}},
{"--cache-reuse 0 d'EXTRA_ARGS écrase la variable", "--cache-reuse 0",
serveSysInfo{ArgEnv: envOf("LLAMA_ARG_CACHE_REUSE", "128")}, nil},
{"LLAMA_ARG_CONTEXT_SHIFT=1", "", serveSysInfo{Help: helpShiftNew, ArgEnv: envOf("LLAMA_ARG_CONTEXT_SHIFT", "1")},
[]string{"(LLAMA_ARG_CONTEXT_SHIFT)"}},
{"moteur récent sans drapeau : rien", "", serveSysInfo{Help: helpShiftNew}, nil},
// Défaut d'un moteur ancien : il jette des jetons sans qu'on ait rien écrit.
{"moteur ancien : glisse par défaut, on le dit", "", serveSysInfo{Help: helpShiftOld},
[]string{"glisse le contexte par défaut"}},
{"moteur ancien, --no-context-shift : rien", "--no-context-shift", serveSysInfo{Help: helpShiftOld}, nil},
{"moteur ancien, LLAMA_ARG_NO_CONTEXT_SHIFT=1 : rien", "",
serveSysInfo{Help: helpShiftOld, ArgEnv: envOf("LLAMA_ARG_NO_CONTEXT_SHIFT", "1")}, nil},
{"moteur inconnu (aide vide) : on ne suppose rien", "", serveSysInfo{}, nil},
} {
t.Run(c.name, func(t *testing.T) {
got := lossyCacheNotes(splitArgs(c.extra), c.si)
if len(got) != len(c.want) {
t.Fatalf("notes = %q, en attendait %d", got, len(c.want))
}
for i, w := range c.want {
if !strings.Contains(got[i], w) {
t.Fatalf("note %q sans %q", got[i], w)
}
}
})
}
}
// Ce que buildServeArgs fait des garde-fous : JAMAIS de drapeau ajouté ou
// retiré, seulement des notes. Sans KV_TYPE, aucun -ctk/-ctv : le moteur garde
// son f16.
func TestBuildServeArgsFidelite(t *testing.T) {
const bin = "/opt/llama/llama-server"
si := serveSysInfo{Help: helpRecent, Model: "/models/m.gguf"}
for _, c := range []struct {
name string
cfg map[string]string
notes []string // fragments attendus parmi les notes ; nil = aucune note de fidélité
}{
{"KV_TYPE absent : ni -ctk ni -ctv, rien à dire", map[string]string{"NGL": "28"}, nil},
{"--swa-full : sans perte, silence", map[string]string{"NGL": "28", "EXTRA_ARGS": "--swa-full"}, nil},
{"cache quantifié par EXTRA_ARGS : vu", map[string]string{"NGL": "28",
"EXTRA_ARGS": "--cache-type-k q8_0 --cache-type-v q8_0"}, []string{"défini par EXTRA_ARGS"}},
{"--cache-reuse 256 : averti", map[string]string{"NGL": "28", "EXTRA_ARGS": "--cache-reuse 256"},
[]string{"--cache-reuse 256"}},
{"--context-shift : averti", map[string]string{"NGL": "28", "EXTRA_ARGS": "--context-shift"},
[]string{"--context-shift"}},
} {
t.Run(c.name, func(t *testing.T) {
extra := splitArgs(c.cfg["EXTRA_ARGS"])
args, _, notes := buildServeArgs(c.cfg, extra, bin, si)
if c.cfg["KV_TYPE"] == "" && !hasAnyFlag(extra, "-ctk", "-ctv", "--cache-type-k", "--cache-type-v") &&
hasAnyFlag(args, "-ctk", "-ctv", "--cache-type-k", "--cache-type-v") {
t.Fatalf("Loki a posé un type de cache de lui-même : %q", args)
}
// La ligne de commande se termine exactement par EXTRA_ARGS, intouché.
if tail := args[len(args)-len(extra):]; strings.Join(tail, " ") != strings.Join(extra, " ") {
t.Fatalf("EXTRA_ARGS modifié : %q", tail)
}
all := strings.Join(notes, "\n")
if c.notes == nil {
if strings.Contains(all, "cache KV") || strings.Contains(all, "avertissement") {
t.Fatalf("note de fidélité inattendue : %q", notes)
}
return
}
for _, w := range c.notes {
if !strings.Contains(all, w) {
t.Fatalf("notes %q sans %q", notes, w)
}
}
})
}
}
// Aucune requête de Loki vers le moteur ne coupe le cache de préfixe
// (cache_prompt:false) ni n'active la réutilisation approximative
// (n_cache_reuse). On vérifie les corps RÉELLEMENT envoyés par le chat et par
// la compaction, contre un faux llama-server local.
func TestRequetesSansCacheApproximatif(t *testing.T) {
testHome(t)
var mu sync.Mutex
var bodies []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
bodies = append(bodies, body)
mu.Unlock()
if body["stream"] == true {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
_, _ = w.Write([]byte(sseChunk("bonjour")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
return
}
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé"}}}})
}))
t.Cleanup(srv.Close)
u, err := url.Parse(srv.URL)
if err != nil {
t.Fatal(err)
}
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "salut"}}, 0.7, Caps{},
func(StreamEvent) bool { return true }); err != nil {
t.Fatalf("runChat : %v", err)
}
if _, err := summarizeTranscript(context.Background(), "user: bonjour\nassistant: salut"); err != nil {
t.Fatalf("compaction : %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(bodies) < 2 {
t.Fatalf("%d requête(s) reçue(s), attendu chat + compaction", len(bodies))
}
for _, b := range bodies {
if v, ok := b["cache_prompt"]; ok && v != true {
t.Errorf("cache_prompt=%v envoyé au moteur : le préfixe serait recalculé ou faussé", v)
}
if _, ok := b["n_cache_reuse"]; ok {
t.Errorf("n_cache_reuse envoyé au moteur : réutilisation approximative du cache")
}
}
}
// Filet sur tout le paquet : les clés cache_prompt et n_cache_reuse n'existent
// dans le code QUE pour le benchmark, qui mesure le prefill et doit donc couper
// le cache. Lecture de l'arbre syntaxique (pas du texte) : un commentaire qui
// les mentionne ne compte pas, un nouveau fichier est couvert d'office.
func TestCleCacheReserveeAuBench(t *testing.T) {
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
allowed := map[string]bool{"llm_bench.go": true}
fset := token.NewFileSet()
for _, f := range files {
if strings.HasSuffix(f, "_test.go") || allowed[f] {
continue
}
file, err := parser.ParseFile(fset, f, nil, parser.SkipObjectResolution)
if err != nil {
t.Fatal(err)
}
banned := func(s string) bool { return s == "cache_prompt" || s == "n_cache_reuse" }
ast.Inspect(file, func(n ast.Node) bool {
// Un champ de structure sérialisé (étiquette json:"cache_prompt")
// enverrait la clé aussi sûrement qu'une map : on lit les étiquettes.
if f, ok := n.(*ast.Field); ok && f.Tag != nil {
if tag, err := strconv.Unquote(f.Tag.Value); err == nil {
if name, _, _ := strings.Cut(reflect.StructTag(tag).Get("json"), ","); banned(name) {
t.Errorf("%s : champ %q hors du benchmark", fset.Position(f.Pos()), name)
}
}
return true
}
lit, ok := n.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
if s, err := strconv.Unquote(lit.Value); err == nil && banned(s) {
t.Errorf("%s : clé %q hors du benchmark", fset.Position(lit.Pos()), s)
}
return true
})
}
}
+138
View File
@@ -0,0 +1,138 @@
package loki
import (
"net"
"reflect"
"testing"
"time"
)
func TestHasAnyFlag(t *testing.T) {
args := []string{"--parallel", "4", "--load-mode=mlock", "-fa"}
if !hasAnyFlag(args, "--parallel", "-np") {
t.Fatal("--parallel devrait être détecté")
}
// Forme --clé=valeur : le nom seul compte.
if !hasAnyFlag(args, "--load-mode") {
t.Fatal("--load-mode=… devrait être détecté")
}
if hasAnyFlag(args, "-ngl", "--n-gpu-layers") {
t.Fatal("-ngl absent, ne devrait pas être détecté")
}
// Une VALEUR qui ressemble à un drapeau ne doit pas compter comme tel :
// ici "-ngl" n'est pas posé, seul --parallel l'est.
if hasAnyFlag(nil, "--parallel") {
t.Fatal("liste vide : rien à détecter")
}
}
func TestNormalizeLoadFlags(t *testing.T) {
cases := []struct {
name string
in []string
supports bool
want []string
}{
{"moteur ancien : anciens drapeaux intacts", []string{"--mlock", "--no-mmap"}, false,
[]string{"--mlock", "--no-mmap"}},
// L'ancien --mlock gardait le mmap : « mlock » de llama.cpp le coupe.
{"mlock seul → mmap+mlock", []string{"--mlock", "-fa"}, true,
[]string{"-fa", "--load-mode", "mmap+mlock"}},
{"no-mmap traduit", []string{"--no-mmap"}, true,
[]string{"--load-mode", "none"}},
{"mmap traduit", []string{"--mmap"}, true,
[]string{"--load-mode", "mmap"}},
{"mlock + no-mmap → mlock", []string{"--no-mmap", "--mlock"}, true,
[]string{"--load-mode", "mlock"}},
{"moteur ancien : none retraduit", []string{"-fa", "--load-mode", "none"}, false,
[]string{"-fa", "--no-mmap"}},
{"moteur ancien : mlock retraduit", []string{"--load-mode", "mlock"}, false,
[]string{"--mlock", "--no-mmap"}},
{"moteur ancien : mmap+mlock retraduit", []string{"-lm", "mmap+mlock"}, false,
[]string{"--mlock"}},
{"moteur ancien : dio abandonné", []string{"--load-mode=dio", "-fa"}, false,
[]string{"-fa"}},
{"--load-mode explicite gagne, vieux drapeaux retirés",
[]string{"--mlock", "--load-mode", "dio"}, true,
[]string{"--load-mode", "dio"}},
{"rien à traduire", []string{"-fa", "--n-cpu-moe", "8"}, true,
[]string{"-fa", "--n-cpu-moe", "8"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, note := normalizeLoadFlags(c.in, c.supports)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("got %q, want %q", got, c.want)
}
// Seul « dio » abandonné sur un moteur ancien se signale : les autres
// traductions gardent le sens demandé.
if wantNote := c.name == "moteur ancien : dio abandonné"; (note != "") != wantNote {
t.Fatalf("note = %q, attendue : %v", note, wantNote)
}
})
}
}
func TestNGLArgs(t *testing.T) {
cases := []struct {
name string
ngl string
fitsItself bool
want []string
wantNote bool
}{
// Le cas qui motive tout : defaultConfig() sème NGL=999 sur chaque
// installation neuve. Personne ne l'a choisi, et le moteur récent
// abandonne son calcul de VRAM dès qu'on lui impose un nombre.
{"sentinelle 999, moteur récent", "999", true, []string{"-ngl", "auto"}, true},
{"sentinelle 999, moteur ancien", "999", false, []string{"-ngl", "999"}, false},
{"nombre choisi, jamais touché", "28", true, []string{"-ngl", "28"}, false},
{"nombre choisi, moteur ancien", "28", false, []string{"-ngl", "28"}, false},
{"all reste all", "all", true, []string{"-ngl", "all"}, false},
{"auto : aucun drapeau", "auto", true, nil, false},
{"auto insensible à la casse", "AUTO", true, nil, false},
{"espaces parasites autour de la sentinelle", " 999 ", true, []string{"-ngl", "auto"}, true},
{"clé absente, moteur récent", "", true, []string{"-ngl", "auto"}, false},
{"clé absente, moteur ancien", "", false, []string{"-ngl", "999"}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, note := nglArgs(c.ngl, c.fitsItself)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("args = %q, want %q", got, c.want)
}
if (note != "") != c.wantNote {
t.Fatalf("note = %q, en voulait-on une ? %v", note, c.wantNote)
}
})
}
}
func TestArgValueEtPortLibre(t *testing.T) {
if got := argValue([]string{"--port", "8080", "-c", "1", "--port", "9000"}, "--port"); got != "9000" {
t.Fatalf("la dernière occurrence doit gagner, got %q", got)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
_, port, _ := net.SplitHostPort(ln.Addr().String())
if waitPortFree("0.0.0.0", port, 600*time.Millisecond) == nil {
t.Fatal("un port occupé doit être refusé")
}
ln.Close()
if err := waitPortFree("0.0.0.0", port, time.Second); err != nil {
t.Fatalf("un port libre doit passer : %v", err)
}
}
func TestSlowKV(t *testing.T) {
for _, c := range []struct {
k, v string
lent bool
}{{"", "", false}, {"q8_0", "q8_0", false}, {"q4_0", "q4_0", false}, {"q8_0", "q4_0", true}, {"q5_1", "q5_1", true}} {
if slowKV(c.k, c.v) != c.lent {
t.Errorf("%s/%s : lent attendu %v", c.k, c.v, c.lent)
}
}
}
+628
View File
@@ -0,0 +1,628 @@
package loki
import (
"fmt"
"os"
"regexp"
"strconv"
"strings"
)
// Experts MoE et chargement du modèle : des avis et un garde-fou, jamais de
// réécriture silencieuse du preset.
//
// moeNotes au lancement et dans l'éditeur : experts placés à la
// main (--fit ne les place pas), UBATCH conseillé quand
// --fit laisse des experts en RAM, moteur sans --fit
// loadModeRisk --load-mode none/mlock/dio/mmap+mlock (ou --no-mmap,
// --mlock) avec plus de poids en RAM que la machine n'en
// a : avertissement ; refus seulement si l'échec est
// certain, LOAD_GUARD=off pour passer outre
// autoPlacementPreset COPIE d'un preset MoE placé à la main, en placement
// automatique ; l'original n'est pas touché
//
// Rien de tout cela ne change ce que calcule le modèle : placement des poids
// et découpage des lots seulement, mêmes types de cache, même contexte.
// otPatterns : chaque motif « regex=tampon » des -ot / --override-tensor (et
// de LLAMA_ARG_OVERRIDE_TENSOR) ; llama.cpp en accepte plusieurs par drapeau,
// séparés par des virgules.
func otPatterns(extra []string, argEnv map[string]string) []string {
vals := flagValues(extra, "-ot", "--override-tensor")
if v := argEnv["LLAMA_ARG_OVERRIDE_TENSOR"]; v != "" {
vals = append(vals, v)
}
var out []string
for _, v := range vals {
for _, el := range strings.Split(v, ",") {
if el = strings.TrimSpace(el); el != "" {
out = append(out, el)
}
}
}
return out
}
// otTargetsExperts : le motif vise-t-il les tenseurs d'experts (ffn_*_exps,
// ffn_*_chexps) ? Un motif qui ne vise que la couche d'entrée
// (per_layer_token_embd) n'en est pas.
func otTargetsExperts(pattern string) bool {
return strings.Contains(strings.ToLower(pattern), "exps")
}
// otOnCPU : le tampon du motif est-il la RAM hôte (CPU, CPU_REPACK…) ?
func otOnCPU(el string) bool {
_, buft, ok := strings.Cut(el, "=")
return ok && strings.HasPrefix(strings.ToUpper(strings.TrimSpace(buft)), "CPU")
}
// cpuExperts dit comment des experts MoE sont envoyés en RAM à la main, ou
// vide. Plus étroit que cpuWeights : un -ot ne compte que s'il vise les
// experts, et le cache KV ou un NGL partiel n'en sont pas.
func cpuExperts(extra []string, argEnv map[string]string) string {
for _, el := range otPatterns(extra, argEnv) {
pat, _, _ := strings.Cut(el, "=")
if otTargetsExperts(pat) && otOnCPU(el) {
return "-ot experts=CPU"
}
}
if hasAnyFlag(extra, "--cpu-moe", "-cmoe") || envTruthy(argEnv["LLAMA_ARG_CPU_MOE"]) {
return "--cpu-moe"
}
for _, v := range append(flagValues(extra, "-ncmoe", "--n-cpu-moe"), argEnv["LLAMA_ARG_N_CPU_MOE"]) {
if v = strings.TrimSpace(v); v != "" && v != "0" {
return "--n-cpu-moe " + v
}
}
return ""
}
// fitFlagRe : « --fit » lui-même dans l'aide, pas seulement --fit-target.
var fitFlagRe = regexp.MustCompile(`(^|[\s,])--fit([\s,=\[]|$)`)
// helpSupportsFit : ce moteur sait-il placer les poids tout seul (--fit) ?
func helpSupportsFit(help string) bool { return fitFlagRe.MatchString(help) }
// serveUBatch : le micro-lot que verra le moteur (-ub d'EXTRA_ARGS, sinon
// UBATCH, sinon le défaut de Loki). 0 = illisible.
func serveUBatch(cfg map[string]string, extra []string) int {
s := flagValue(extra, "-ub", "--ubatch-size")
if s == "" {
s = strings.TrimSpace(cfg["UBATCH"])
}
if s == "" {
s = "512"
}
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n < 0 {
return 0
}
return n
}
// gibText : des Mio en « 12,3 Gio ».
func gibText(mib int64) string {
return strings.Replace(strconv.FormatFloat(float64(mib)/1024, 'f', 1, 64), ".", ",", 1) + " Gio"
}
// moeNotes : ce qu'il faut savoir du placement d'un MoE, pour ce lancement.
// Fonction pure, comme nglArgs : des notes seulement, aucun drapeau.
//
// - experts placés à la main (-ot visant les experts, --n-cpu-moe,
// --cpu-moe) : --fit ne les place pas. Monter UBATCH seul y serait un
// piège : le tampon de calcul grossit sur CHAQUE carte, sans personne pour
// rééquilibrer, et le moteur finit en « cudaMalloc failed » ;
// - placement auto, modèle plus gros que la VRAM : --fit laisse des experts
// en RAM, recopiés vers le GPU à chaque micro-lot du prefill — un UBATCH de
// 2048 ou plus en paie quatre fois moins. --fit, lui, retient de la place
// pour ce tampon plus gros ;
// - moteur sans --fit (ancien, fork) : rien ne sort les experts du GPU,
// garder --n-cpu-moe ;
// - --fit coupé par un autre -ot : même chose.
//
// Muette sur un modèle dense, un preset externe, une VRAM inconnue (macOS,
// --device, AMD) ou une aide du moteur illisible.
func moeNotes(cfg map[string]string, extra []string, si serveSysInfo) []string {
if isExternalConfig(cfg) || si.GGUF == nil || si.GGUF.ExpertCount <= 0 {
return nil
}
ub := serveUBatch(cfg, extra)
if r := cpuExperts(extra, si.ArgEnv); r != "" {
n := "experts placés à la main (" + r + ") : --fit ne les place pas"
if ub > 0 && ub <= 512 {
n += " ; UBATCH plus grand = tampon de calcul plus gros sur chaque GPU : monte --n-cpu-moe d'abord, " +
"ou passe au placement auto (--fit, « Dupliquer en placement auto » dans l'éditeur)"
}
return []string{n}
}
modelMiB := si.ModelBytes >> 20
if modelMiB <= 0 || si.VRAMMiB <= 0 || si.UnifiedMem || strings.TrimSpace(si.Help) == "" {
return nil
}
cards := int64(si.GPUs)
if cards < 1 {
cards = 1
}
if modelMiB <= si.VRAMMiB-fitTargetMin*cards {
return nil // tout tient sur GPU : pas d'expert recopié
}
sizes := "modèle " + gibText(modelMiB) + ", VRAM " + gibText(si.VRAMMiB)
if !helpSupportsFit(si.Help) {
// Un NGL chiffré sous 999 laisse déjà des couches (et leurs experts) au
// CPU : l'utilisateur a tranché.
if n, err := strconv.Atoi(strings.TrimSpace(firstNonEmpty(flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers"), cfg["NGL"]))); err == nil && n < 999 {
return nil
}
return []string{"MoE plus gros que la VRAM (" + sizes + ") : ce moteur ne place pas les experts (pas de --fit) — " +
"garde --n-cpu-moe"}
}
if why := fitBlocker(cfg, extra, si); why != "" {
if why == "surcharge de tenseurs" {
return []string{"MoE plus gros que la VRAM (" + sizes + ") et --fit coupé par un -ot qui ne vise pas les " +
"experts : rien ne les sort du GPU — --n-cpu-moe, ou retire ce -ot"}
}
return nil
}
if ub > 0 && ub <= 512 {
return []string{"UBATCH 2048+ recommandé : --fit laisse des experts en RAM (" + sizes + "), recopiés vers le " +
"GPU à chaque micro-lot du prefill ; BATCH 4096 avec — à mesurer (bench complet)"}
}
return nil
}
// firstNonEmpty : la première valeur non vide.
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// loadArgEnv : les variables qui règlent le chargement sans drapeau.
// LLAMA_ARG_RPC n'en est pas une, mais dit que des poids partent sur des
// machines distantes (loadModeRisk).
var loadArgEnv = []string{"LLAMA_ARG_LOAD_MODE", "LLAMA_ARG_MLOCK", "LLAMA_ARG_NO_MMAP", "LLAMA_ARG_RPC"}
// probeLoadEnv relève ces variables. Appelée après probeServeGPUs, qui
// remet ArgEnv à zéro.
func probeLoadEnv(si *serveSysInfo) {
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range loadArgEnv {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
si.ArgEnv[k] = v
}
}
}
// residentLoadMode : le mode de chargement qui garde ENTIÈREMENT en RAM les
// poids laissés au CPU, tel qu'écrit (« --load-mode none », « --no-mmap »…),
// ou vide. mmap (et auto) laisse le système relire les pages au besoin : lent
// quand la RAM manque, jamais fatal. none et dio copient les poids en mémoire
// anonyme, mlock et mmap+mlock les verrouillent. Lu sur la ligne finale
// (normalizeLoadFlags a déjà traduit l'une ou l'autre forme), puis dans les
// LLAMA_ARG_* si elle se tait.
func residentLoadMode(args []string, argEnv map[string]string) string {
mode := strings.ToLower(strings.TrimSpace(flagValue(args, "--load-mode", "-lm")))
mlock, nommap := hasAnyFlag(args, "--mlock"), hasAnyFlag(args, "--no-mmap")
if mode == "" && !mlock && !nommap {
mode = strings.ToLower(strings.TrimSpace(argEnv["LLAMA_ARG_LOAD_MODE"]))
if mode == "" {
mlock, nommap = envTruthy(argEnv["LLAMA_ARG_MLOCK"]), envTruthy(argEnv["LLAMA_ARG_NO_MMAP"])
}
}
switch mode {
case "none", "mlock", "dio", "mmap+mlock":
return "--load-mode " + mode
}
switch {
case mlock && nommap:
return "--mlock --no-mmap"
case mlock:
return "--mlock"
case nommap:
return "--no-mmap"
}
return ""
}
// loadGuardMarker : la fin du message de refus, que l'interface reconnaît
// dans le journal du service (modelLoadError).
const loadGuardMarker = "LOAD_GUARD=off pour lancer quand même"
// loadGuardOff : LOAD_GUARD=off, la clé d'échappement du refus.
func loadGuardOff(cfg map[string]string) bool {
switch strings.ToLower(strings.TrimSpace(cfg["LOAD_GUARD"])) {
case "off", "0", "false", "non", "no":
return true
}
return false
}
// loadGuardExitStatus : le code de sortie de « loki serve » quand LOAD_GUARD
// refuse. Le refus est certain et ne dépend que du preset et de la machine :
// relancer ne changerait rien. Sous systemd, Restart=on-failure relançait
// pourtant toutes les trois secondes, sans fin ; l'unité écrite par
// « loki install » porte donc RestartPreventExitStatus pour ce code-là
// (EX_CONFIG de sysexits.h : erreur de configuration). Le conteneur, lui, ne
// relance jamais le moteur de lui-même.
const loadGuardExitStatus = 78
// unitPreventsRefusalRestart : l'unité systemd (son texte) s'abstient-elle de
// relancer sur loadGuardExitStatus ? Une unité écrite par une version
// d'avant ne le fait pas.
func unitPreventsRefusalRestart(unit string) bool {
for _, line := range strings.Split(unit, "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok || strings.TrimSpace(k) != "RestartPreventExitStatus" {
continue
}
for _, f := range strings.Fields(v) {
if f == strconv.Itoa(loadGuardExitStatus) {
return true
}
}
}
return false
}
// loadGuardRefusal : l'erreur rendue par cmdServe pour un refus, avec son code
// de sortie. Un superviseur qui relancerait quand même sur ce code (unité
// systemd d'une version d'avant, launchd, qui relance toute sortie non nulle)
// reçoit 0 : une sortie « propre » n'est pas relancée, et la raison reste en
// clair dans le journal, où l'interface la reconnaît (modelLoadError). Le
// moteur d'essai de l'optimiseur garde le vrai code : c'est lui qui dit
// l'échec à « loki tune ».
func loadGuardRefusal(refuse string, trial bool) error {
code := loadGuardExitStatus
if !trial {
if restarts, hint := supervisorRestartsRefusal(); restarts {
code = 0
if hint != "" {
fmt.Fprintln(os.Stderr, "[loki serve] "+hint)
}
}
}
return &exitStatusError{code: code, err: fmt.Errorf("%s", refuse)}
}
// loadModeRisk : un mode de chargement résident avec trop de poids en RAM.
// Fonction pure ; args est la ligne finale (ou EXTRA_ARGS normalisé).
//
// llama.cpp #26110 : un modèle de 65 Go sur 60 Go de RAM, --load-mode none →
// swap, décodage de 25 à 7,5 t/s ; mlock → processus tué faute de mémoire.
// Seuls comptent les poids qui RESTENT en RAM : ceux qui partent en VRAM n'y
// séjournent pas. On en connaît une borne basse — modèle moins VRAM totale
// (tout le modèle sur macOS, mémoire unifiée) — et une estimation haute quand
// la VRAM est inconnue (le modèle entier).
//
// - refuse : la borne basse dépasse 90 % de la RAM effective (limite du
// conteneur comprise) — l'échec est certain. LOAD_GUARD=off lève le refus ;
// - warn : l'estimation dépasse 80 % — on prévient, on lance.
//
// Un refus fait sortir « loki serve » en erreur, comme un modèle introuvable :
// mieux vaut la raison en clair qu'un moteur tué en boucle par le noyau. Le code
// de sortie (loadGuardRefusal) empêche le superviseur de relancer en boucle.
func loadModeRisk(cfg map[string]string, args []string, si serveSysInfo) (warn, refuse string) {
if isExternalConfig(cfg) {
return "", ""
}
// Un moteur qui connaît --load-mode n'a plus LLAMA_ARG_MLOCK ni
// LLAMA_ARG_NO_MMAP : restées dans l'environnement, elles n'ont aucun effet
// et ne doivent pas faire refuser un lancement en mmap.
env := si.ArgEnv
if helpSupportsLoadMode(si.Help) && (env["LLAMA_ARG_MLOCK"] != "" || env["LLAMA_ARG_NO_MMAP"] != "") {
env = map[string]string{"LLAMA_ARG_LOAD_MODE": si.ArgEnv["LLAMA_ARG_LOAD_MODE"]}
}
mode := residentLoadMode(args, env)
modelMiB := si.ModelBytes >> 20
if mode == "" || modelMiB <= 0 || si.RAMMiB <= 0 {
return "", ""
}
// Serveurs RPC : une part des poids part sur d'autres machines, la VRAM
// locale ne borne plus rien — inconnu, donc un avis au plus, jamais un refus.
remote := hasAnyFlag(args, "--rpc") || strings.TrimSpace(si.ArgEnv["LLAMA_ARG_RPC"]) != ""
low, est, upTo, unsure := int64(0), modelMiB, true, false
switch {
case remote:
case si.UnifiedMem:
low, est, upTo = modelMiB, modelMiB, false
case si.EngineVRAMMiB > 0:
// Les cartes que le moteur liste lui-même : rien au-delà ne peut
// recevoir de poids, la borne basse est sûre.
low = max(modelMiB-si.EngineVRAMMiB, 0)
est, upTo = low, false
case si.VRAMMiB > 0:
// nvidia-smi seulement : un moteur Vulkan sur cartes mixtes NVIDIA +
// AMD en utilise davantage, un moteur ROCm d'autres. Une estimation,
// jamais une borne : un avis au plus (voir probeLoadDevices).
est, upTo, unsure = max(modelMiB-si.VRAMMiB, 0), false, true
}
ram := si.RAMMiB
what := "environ "
if upTo {
what = "jusqu'à "
}
if unsure && est*10 > ram*9 && !loadGuardOff(cfg) {
return fmt.Sprintf("%s : environ %s de poids en RAM (modèle %s, VRAM NVIDIA %s) pour %s — échec probable ; "+
"refus seulement si les cartes listées par le moteur le confirment, lancement sinon ; "+
"--load-mode mmap est plus sûr", mode, gibText(est), gibText(modelMiB), gibText(si.VRAMMiB), gibText(ram)), ""
}
if low*10 > ram*9 {
msg := fmt.Sprintf("%s : au moins %s de poids restent en RAM (modèle %s) pour %s de RAM, limite du conteneur "+
"comprise — échec certain : swap et décodage effondré, ou processus tué (llama.cpp #26110). "+
"--load-mode mmap laisse le système relire les pages au besoin", mode, gibText(low), gibText(modelMiB), gibText(ram))
if loadGuardOff(cfg) {
return msg + " ; LOAD_GUARD=off : lancé quand même", ""
}
return "", msg + " ; " + loadGuardMarker
}
if est*10 > ram*8 {
return fmt.Sprintf("%s : %s%s de poids en RAM pour %s — au-delà, swap (décodage de 25 à 7,5 t/s dans "+
"llama.cpp #26110) ou processus tué ; --load-mode mmap est plus sûr", mode, what, gibText(est), gibText(ram)), ""
}
return "", ""
}
// loadGuardNeedsDevices : un refus se profile sur la seule VRAM NVIDIA
// (nvidia-smi) ? Il ne sera prononcé que sur les cartes du moteur lui-même —
// à lire d'abord (probeLoadDevices). Fonction pure.
func loadGuardNeedsDevices(cfg map[string]string, args []string, si serveSysInfo) bool {
if si.EngineVRAMMiB > 0 || si.VRAMMiB <= 0 {
return false
}
si.EngineVRAMMiB = si.VRAMMiB
_, refuse := loadModeRisk(cfg, args, si)
return refuse != ""
}
// engineDevsVRAM : la VRAM des cartes listées par le moteur, toutes
// additionnées — sans --device, llama.cpp ne place rien ailleurs, et une carte
// qu'il listerait sans s'en servir (iGPU) ne fait que rendre le refus plus
// rare. 0 (inconnu) si la liste est vide ou si une carte annonce 0 Mio (déjà
// pleine : lecture transitoire, voir handleBackendDevices).
func engineDevsVRAM(devs []splitDev) int64 {
var total int64
for _, d := range devs {
if d.TotalMiB <= 0 {
return 0
}
total += d.TotalMiB
}
return total
}
// probeLoadDevices : seulement quand loadGuardNeedsDevices — un mode de
// chargement résident qu'on s'apprête à refuser. Les cartes de SPLIT_MODE si
// elles sont déjà lues, sinon --list-devices dans l'environnement du
// lancement (sélection GPU comprise). Échec de lecture : EngineVRAMMiB reste
// à 0, loadModeRisk avertit sans refuser. args : EXTRA_ARGS normalisé.
func probeLoadDevices(cfg map[string]string, args []string, bin string, si *serveSysInfo) {
if !loadGuardNeedsDevices(cfg, args, *si) {
return
}
devs := si.SplitDevs
if len(devs) == 0 {
devs = listEngineDevices(bin)
}
si.EngineVRAMMiB = engineDevsVRAM(devs)
}
// previewServeHelp : l'aide supposée d'un moteur récent, pour les aperçus de
// l'éditeur (le process web ne lance pas le moteur pour lire son aide). Le
// lancement, lui, décide sur la vraie aide.
const previewServeHelp = "--load-mode --fit [on|off] --fit-target"
// moePreview : les avis de moeNotes et loadModeRisk pour l'éditeur, calculés
// comme au lancement, sur un moteur supposé récent.
func moePreview(content string) (notes []string, load string) {
cfg := parseEnv(content)
if isExternalConfig(cfg) {
return nil, ""
}
si := serveSysInfo{Help: previewServeHelp, ArgEnv: map[string]string{}}
for _, k := range append(append(append([]string{}, serveArgEnv...), fitArgEnv...), loadArgEnv...) {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
if m := strings.TrimSpace(cfg["MODEL"]); m != "" {
if p, err := resolveServeModelPath(m); err == nil {
si.Model = p
}
}
if si.Model == "" {
return nil, ""
}
extra := splitArgs(cfg["EXTRA_ARGS"])
probeCacheRAM(cfg, extra, &si)
norm, _ := normalizeLoadFlags(extra, true)
// Les cartes du moteur, sans le lancer : la dernière liste complète qu'il a
// donnée à l'éditeur (devices.json). Absente : un avis, pas un refus.
if loadGuardNeedsDevices(cfg, norm, si) {
bin := prebuiltResolveBin(firstNonEmpty(cfg["BIN"], ReadConfig()["BIN"]))
if list, ok := devPersistGet(bin + "\x00" + cfg["CUDA_VISIBLE_DEVICES"]); ok {
si.EngineVRAMMiB = engineDevsVRAM(splitDevsFrom(list))
}
}
warn, refuse := loadModeRisk(cfg, norm, si)
if refuse != "" {
warn = "refusé au lancement — " + refuse
}
return moeNotes(cfg, extra, si), warn
}
// autoPlacementPreset prépare la COPIE d'un preset MoE placé à la main, en
// placement automatique. Fonction pure : elle rend le nouveau contenu et la
// liste de ce qui change ; l'appelant l'enregistre sous un nouveau nom, et le
// preset d'origine reste tel quel — le repli si --fit échoue.
//
// Dans la copie :
//
// - retirés d'EXTRA_ARGS : les -ot qui visent les experts, un -ot qui
// n'envoie au CPU que per_layer_token_embd (couche d'entrée : llama.cpp la
// laisse toujours au CPU, le motif est redondant mais coupe --fit),
// --n-cpu-moe, --cpu-moe, -ngl, --tensor-split, --fit off, -b/-ub et les
// drapeaux de chargement ;
// - NGL retiré (Loki écrit -ngl auto sur un moteur qui sait placer),
// UBATCH=2048, BATCH=4096, « --load-mode mmap » (rien sur un moteur
// ancien, qui charge déjà ainsi) ;
// - CTX, cache KV, échantillonnage, raisonnement : intouchés.
//
// Refus (err) quand la copie ne tournerait pas en --fit — un autre -ot, -sm
// row/tensor, SPLIT_MODE=tensor, --n-cpu-ffn — ou quand CTX vaut 0 (--fit
// pourrait réduire le contexte), et quand il n'y a rien à migrer. Jamais de
// --tensor-read-lazy ni --lazy-mode : leur nom change d'un moteur à l'autre.
func autoPlacementPreset(content string) (out string, changes []string, err error) {
cfg := parseEnv(content)
if isExternalConfig(cfg) {
return "", nil, fmt.Errorf("preset externe : le modèle tourne ailleurs")
}
extra := splitArgs(cfg["EXTRA_ARGS"])
var kept []string
migrated := false
drop := func(s string) { changes = append(changes, "retiré : "+s) }
for i := 0; i < len(extra); i++ {
a := extra[i]
name, val, hasEq := strings.Cut(a, "=")
value := func() string {
if hasEq {
return val
}
if i+1 < len(extra) {
i++
return extra[i]
}
return ""
}
switch name {
case "-ot", "--override-tensor":
v := value()
var rest []string
for _, el := range strings.Split(v, ",") {
el = strings.TrimSpace(el)
pat, _, _ := strings.Cut(el, "=")
switch {
case el == "":
case otTargetsExperts(pat):
drop(name + " " + el + " (experts : --fit les place)")
migrated = true
case strings.Contains(strings.ToLower(pat), "per_layer_token_embd") && otOnCPU(el):
drop(name + " " + el + " (couche d'entrée, toujours au CPU)")
default:
rest = append(rest, el)
}
}
if len(rest) > 0 {
kept = append(kept, name, strings.Join(rest, ","))
}
case "-ncmoe", "--n-cpu-moe":
v := value()
drop(name + " " + v)
if strings.TrimSpace(v) != "0" {
migrated = true
}
case "-cmoe", "--cpu-moe":
drop(name)
migrated = true
case "-ngl", "--n-gpu-layers", "--gpu-layers", "-ts", "--tensor-split",
"-b", "--batch-size", "-ub", "--ubatch-size", "--load-mode", "-lm":
drop(name + " " + value())
case "--mlock", "--no-mmap", "--mmap":
drop(name)
case "-fit", "--fit":
v := value()
switch strings.ToLower(strings.TrimSpace(v)) {
case "off", "disabled", "false", "0":
drop(name + " " + v)
default:
kept = append(kept, name, v)
}
default:
kept = append(kept, a)
}
}
if !migrated {
return "", nil, fmt.Errorf("rien à migrer : ce preset ne place pas d'experts à la main (-ot …exps…, --n-cpu-moe, --cpu-moe)")
}
kept = append(kept, "--load-mode", "mmap")
nc := map[string]string{}
for k, v := range cfg {
nc[k] = v
}
nc["EXTRA_ARGS"] = joinArgs(kept)
nc["UBATCH"], nc["BATCH"] = "2048", "4096"
delete(nc, "NGL")
if ctx, ok := ctxFixed(nc, kept); !ok {
return "", nil, fmt.Errorf("contexte « %s » non chiffré : --fit pourrait le réduire pour tenir — donne un CTX chiffré d'abord", ctx)
}
if splitModeKey(nc) == "tensor" {
return "", nil, fmt.Errorf("SPLIT_MODE=tensor coupe --fit : retire-le d'abord")
}
if why := fitBlocker(nc, kept, serveSysInfo{Help: previewServeHelp, ArgEnv: map[string]string{}}); why != "" {
return "", nil, fmt.Errorf("--fit resterait inactif (%s) : retire-le à la main d'abord", why)
}
if ngl := strings.TrimSpace(cfg["NGL"]); ngl != "" {
changes = append(changes, "NGL="+ngl+" retiré : le moteur place les couches (-ngl auto)")
}
changes = append(changes, "UBATCH=2048, BATCH=4096 (avant : "+firstNonEmpty(cfg["UBATCH"], "512")+" / "+
firstNonEmpty(cfg["BATCH"], "2048")+")", "--load-mode mmap")
ctx, _ := ctxFixed(nc, kept)
changes = append(changes, "CTX conservé : "+ctx)
if k, v := serveKVTypes(nc); k != "" || v != "" ||
flagValue(kept, "-ctk", "--cache-type-k") != "" || flagValue(kept, "-ctv", "--cache-type-v") != "" {
changes = append(changes, "cache KV conservé tel quel — garde-le identique pour comparer les deux presets")
}
out = content
out = presetSetKey(out, "EXTRA_ARGS", nc["EXTRA_ARGS"])
out = presetSetKey(out, "UBATCH", "2048")
out = presetSetKey(out, "BATCH", "4096")
out = presetSetKey(out, "NGL", "")
return out, changes, nil
}
// joinArgs : l'inverse de splitArgs — un argument qui contient un blanc est
// entouré d'apostrophes (de guillemets s'il en contient une), pour être relu
// d'un seul tenant.
func joinArgs(args []string) string {
parts := make([]string, len(args))
for i, a := range args {
switch {
case a == "":
parts[i] = `""`
case strings.ContainsAny(a, " \t"):
if strings.Contains(a, "'") {
parts[i] = `"` + a + `"`
} else {
parts[i] = "'" + a + "'"
}
default:
parts[i] = a
}
}
return strings.Join(parts, " ")
}
// presetSetKey remplace les lignes KEY=… d'un preset (toutes : parseEnv
// retient la dernière, une ancienne restée plus bas l'emporterait), l'ajoute à
// la fin si elle manque, les retire si val est vide. Le reste du fichier —
// commentaires, ordre, NAME — ne bouge pas : c'est l'équivalent de cfgWriteKey
// côté éditeur.
func presetSetKey(content, key, val string) string {
re := regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?` + regexp.QuoteMeta(key) + `[ \t]*=.*$`)
if val == "" {
return regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?`+regexp.QuoteMeta(key)+`[ \t]*=.*(?:\n|$)`).
ReplaceAllLiteralString(content, "")
}
line := key + "=" + quoteValue(val)
if re.MatchString(content) {
return re.ReplaceAllLiteralString(content, line)
}
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
return content + line + "\n"
}
+389
View File
@@ -0,0 +1,389 @@
package loki
import (
"errors"
"fmt"
"reflect"
"strings"
"testing"
)
const gib = int64(1) << 30
// Un -ot ne compte comme experts en RAM que s'il vise les experts ET la RAM ;
// celui de la couche d'entrée (per_layer_token_embd) n'en est pas.
func TestCpuExperts(t *testing.T) {
cases := []struct {
extra string
env map[string]string
want bool
}{
{`-ot per_layer_token_embd.weight=CPU`, nil, false},
{`-ot "blk\..*\.ffn_.*_exps\.=CPU"`, nil, true},
{`--override-tensor=per_layer_token_embd.weight=CPU,\.ffn_(up|down)_exps=CPU`, nil, true},
{`-ot \.ffn_.*_exps=CUDA1`, nil, false},
{`-cmoe`, nil, true},
{`--n-cpu-moe 0`, nil, false},
{`--n-cpu-moe 40`, nil, true},
{`-ncmoe=12`, nil, true},
{``, map[string]string{"LLAMA_ARG_N_CPU_MOE": "20"}, true},
{``, map[string]string{"LLAMA_ARG_CPU_MOE": "1"}, true},
{`-nkvo -ngl 20`, nil, false},
}
for _, c := range cases {
if got := cpuExperts(splitArgs(c.extra), c.env) != ""; got != c.want {
t.Errorf("cpuExperts(%q, %v) = %v, want %v", c.extra, c.env, got, c.want)
}
}
}
// moeNotes : les avis, au bon endroit et seulement là.
func TestMoeNotes(t *testing.T) {
moe := &GGUFInfo{ExpertCount: 128, BlockCount: 48}
big := serveSysInfo{Help: helpFit, GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10, GPUs: 2}
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want string // sous-chaîne attendue ; vide = aucune note
}{
{name: "modèle dense : rien",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 40"},
si: serveSysInfo{Help: helpFit, GGUF: &GGUFInfo{BlockCount: 64}, ModelBytes: 82 * gib, VRAMMiB: 28 << 10}},
{name: "placement manuel, UBATCH 512 : ne pas monter UBATCH seul",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 40"}, si: big, want: "monte --n-cpu-moe d'abord"},
{name: "placement manuel, UBATCH 2048 : --fit ne les place pas, sans conseil de lot",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 40", "UBATCH": "2048"}, si: big, want: "--fit ne les place pas"},
{name: "placement auto, MoE plus gros que la VRAM, UBATCH 512 : UBATCH 2048+",
cfg: map[string]string{}, si: big, want: "UBATCH 2048+ recommandé"},
{name: "placement auto, UBATCH 4096 : rien",
cfg: map[string]string{"UBATCH": "4096", "BATCH": "4096"}, si: big},
{name: "-ub dans EXTRA_ARGS l'emporte sur UBATCH",
cfg: map[string]string{"UBATCH": "512", "EXTRA_ARGS": "-ub 2048"}, si: big},
{name: "le modèle tient en VRAM : rien",
cfg: map[string]string{},
si: serveSysInfo{Help: helpFit, GGUF: moe, ModelBytes: 20 * gib, VRAMMiB: 28 << 10, GPUs: 2}},
{name: "moteur sans --fit : garder --n-cpu-moe",
cfg: map[string]string{}, si: serveSysInfo{Help: helpOld, GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10},
want: "garde --n-cpu-moe"},
{name: "moteur sans --fit, NGL partiel : l'utilisateur a tranché",
cfg: map[string]string{"NGL": "20"}, si: serveSysInfo{Help: helpOld, GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10}},
{name: "aide illisible : rien",
cfg: map[string]string{}, si: serveSysInfo{GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10}},
{name: "-ot de la seule couche d'entrée : --fit coupé, experts tous sur GPU",
cfg: map[string]string{"EXTRA_ARGS": "-ot per_layer_token_embd.weight=CPU"}, si: big,
want: "-ot qui ne vise pas les experts"},
{name: "VRAM inconnue : rien",
cfg: map[string]string{}, si: serveSysInfo{Help: helpFit, GGUF: moe, ModelBytes: 82 * gib}},
{name: "macOS, mémoire unifiée : rien",
cfg: map[string]string{}, si: serveSysInfo{Help: helpFit, GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10, UnifiedMem: true}},
{name: "preset externe : rien",
cfg: map[string]string{extKeyFlag: "1", extKeyURL: "https://api.example.com/v1", "EXTRA_ARGS": "--n-cpu-moe 40"}, si: big},
}
for _, c := range cases {
notes := moeNotes(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
switch {
case c.want == "" && len(notes) != 0:
t.Errorf("%s : notes inattendues %q", c.name, notes)
case c.want != "" && (len(notes) != 1 || !strings.Contains(notes[0], c.want)):
t.Errorf("%s : notes %q, attendu %q", c.name, notes, c.want)
}
}
// Placement manuel en UBATCH 2048 : pas de conseil de lot.
n := moeNotes(map[string]string{"UBATCH": "2048"}, splitArgs("--n-cpu-moe 40"), big)
if len(n) != 1 || strings.Contains(n[0], "UBATCH") {
t.Errorf("conseil de lot en trop : %q", n)
}
}
// loadModeRisk : un avertissement d'ordinaire, un refus seulement quand la
// borne basse des poids en RAM dépasse 90 % de la RAM effective.
func TestLoadModeRisk(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
args string
si serveSysInfo
warn, refuse bool
}{
{name: "mmap : rien",
args: "--load-mode mmap", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}},
{name: "sans drapeau : rien",
si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}},
{name: "none, 66 Gio certains en RAM pour 60 : refus (#26110)",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true},
{name: "même chose, LOAD_GUARD=off : avertissement seulement",
cfg: map[string]string{"LOAD_GUARD": "off"}, args: "--load-mode none",
si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
{name: "mlock, 54 Gio en RAM pour 64 : avertissement",
args: "--load-mode mlock", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 28 << 10, RAMMiB: 64 << 10}, warn: true},
{name: "dense tout en VRAM sur une machine à peu de RAM : rien (pas de faux positif)",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 16 * gib, VRAMMiB: 24 << 10, RAMMiB: 8 << 10}},
{name: "VRAM inconnue : avertissement « jusqu'à », jamais de refus",
args: "--load-mode dio", si: serveSysInfo{ModelBytes: 82 * gib, RAMMiB: 60 << 10}, warn: true},
{name: "macOS, mémoire unifiée : tout le modèle compte",
args: "--load-mode mmap+mlock", si: serveSysInfo{ModelBytes: 60 * gib, RAMMiB: 64 << 10, UnifiedMem: true}, refuse: true},
{name: "moteur ancien : --no-mmap",
args: "--no-mmap", si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true},
{name: "moteur ancien : --mlock --no-mmap",
args: "--mlock --no-mmap", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 28 << 10, RAMMiB: 64 << 10}, warn: true},
{name: "LLAMA_ARG_NO_MMAP dans l'environnement",
si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10,
ArgEnv: map[string]string{"LLAMA_ARG_NO_MMAP": "1"}}, refuse: true},
// Corrections de relecture du lot 2 : pas de refus sur une borne fausse.
{name: "LLAMA_ARG_NO_MMAP sans effet sur un moteur à --load-mode : rien",
si: serveSysInfo{Help: "--load-mode", ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10,
ArgEnv: map[string]string{"LLAMA_ARG_NO_MMAP": "1"}}},
{name: "--rpc : une part des poids part ailleurs, avis seulement",
args: "--load-mode none --rpc 10.0.0.2:50052", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
{name: "LLAMA_ARG_RPC : de même",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10,
ArgEnv: map[string]string{"LLAMA_ARG_RPC": "10.0.0.2:50052"}}, warn: true},
{name: "limite du conteneur (RAM effective) : refus",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 40 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 16 << 10}, refuse: true},
// Lot 3 : la VRAM comptée est celle des cartes du moteur ; nvidia-smi
// seul ne fait jamais refuser.
{name: "VRAM NVIDIA seule, cartes du moteur non lues : avertissement, jamais de refus",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
{name: "VRAM NVIDIA seule, LOAD_GUARD=off : l'avis ordinaire",
cfg: map[string]string{"LOAD_GUARD": "off"}, args: "--load-mode none",
si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
{name: "Vulkan, NVIDIA 12 Gio + AMD 16 Gio listées par le moteur : rien (faux refus sur la seule VRAM NVIDIA)",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 70 * gib, VRAMMiB: 12 << 10, EngineVRAMMiB: 28 << 10, RAMMiB: 60 << 10}},
{name: "taille inconnue : rien",
args: "--load-mode none", si: serveSysInfo{VRAMMiB: 16 << 10, RAMMiB: 16 << 10}},
}
for _, c := range cases {
cfg := c.cfg
if cfg == nil {
cfg = map[string]string{}
}
warn, refuse := loadModeRisk(cfg, splitArgs(c.args), c.si)
if (warn != "") != c.warn || (refuse != "") != c.refuse {
t.Errorf("%s : warn=%q refuse=%q", c.name, warn, refuse)
}
if refuse != "" && !strings.Contains(refuse, "LOAD_GUARD=off") {
t.Errorf("%s : refus sans clé d'échappement : %q", c.name, refuse)
}
if loadGuardOff(cfg) && strings.Contains(warn, "refus") {
t.Errorf("%s : refus évoqué avec LOAD_GUARD=off : %q", c.name, warn)
}
}
}
// Sans ces clés, la ligne et l'environnement ne bougent pas quand le modèle
// est un MoE : seules des notes s'ajoutent.
func TestBuildServeArgsMoeNotesOnly(t *testing.T) {
const bin, model = "/opt/llama/llama-server", "/models/Qwen-MoE-Q4.gguf"
cfg := map[string]string{"CTX": "65536", "EXTRA_ARGS": "--n-cpu-moe 40 --load-mode none"}
extra := splitArgs(cfg["EXTRA_ARGS"])
plain := serveSysInfo{Help: helpFit, Model: model}
moe := plain
moe.GGUF, moe.ModelBytes, moe.VRAMMiB, moe.RAMMiB = &GGUFInfo{ExpertCount: 128}, 82*gib, 28<<10, 64<<10
a1, e1, n1 := buildServeArgs(cfg, extra, bin, plain)
a2, e2, n2 := buildServeArgs(cfg, extra, bin, moe)
if !reflect.DeepEqual(a1, a2) || !reflect.DeepEqual(e1, e2) {
t.Fatalf("la ligne a bougé :\n%q\n%q\nenv %v / %v", a1, a2, e1, e2)
}
if len(n2) != len(n1)+2 {
t.Fatalf("notes attendues (placement manuel + chargement) : %q", n2)
}
}
// La copie en placement auto du preset MoE de l'utilisateur.
func TestAutoPlacementPreset(t *testing.T) {
src := "# NAME=QWEN 3.6 MOE\n# experts à la main\nMODEL=Qwen-MoE-Q4.gguf\nCTX=65536\nNGL=999\nUBATCH=512\n" +
`EXTRA_ARGS="-ot per_layer_token_embd.weight=CPU --n-cpu-moe 40 --cache-type-k q8_0 --cache-type-v q8_0 --jinja --mlock --chat-template-file '/mes modèles/tpl.jinja'"` + "\n"
out, changes, err := autoPlacementPreset(src)
if err != nil {
t.Fatal(err)
}
cfg := parseEnv(out)
wantExtra := []string{"--cache-type-k", "q8_0", "--cache-type-v", "q8_0", "--jinja",
"--chat-template-file", "/mes modèles/tpl.jinja", "--load-mode", "mmap"}
if got := splitArgs(cfg["EXTRA_ARGS"]); !reflect.DeepEqual(got, wantExtra) {
t.Errorf("EXTRA_ARGS = %q\nwant %q", got, wantExtra)
}
if cfg["UBATCH"] != "2048" || cfg["BATCH"] != "4096" || cfg["CTX"] != "65536" || cfg["MODEL"] != "Qwen-MoE-Q4.gguf" {
t.Errorf("clés : %v", cfg)
}
if _, ok := cfg["NGL"]; ok {
t.Errorf("NGL gardé : %v", cfg)
}
if !strings.Contains(out, "# experts à la main") || !strings.HasPrefix(out, "# NAME=QWEN 3.6 MOE") {
t.Errorf("commentaires perdus :\n%s", out)
}
if len(changes) == 0 || !strings.Contains(strings.Join(changes, "\n"), "cache KV conservé") {
t.Errorf("changements : %q", changes)
}
// L'original n'est pas touché (chaîne passée par valeur, mais on vérifie la
// fonction : elle ne rend rien d'autre que la copie).
if parseEnv(src)["UBATCH"] != "512" {
t.Error("source modifiée")
}
refus := []struct{ name, content string }{
{"rien à migrer", "CTX=65536\nEXTRA_ARGS=--jinja\n"},
{"CTX=0", "CTX=0\nEXTRA_ARGS=--n-cpu-moe 40\n"},
{"-ot hors experts restant", `CTX=65536` + "\n" + `EXTRA_ARGS="-ot blk\.0\.attn.*=CUDA0,\.ffn_.*_exps=CPU"` + "\n"},
{"-sm row", "CTX=65536\nEXTRA_ARGS=\"--n-cpu-moe 40 -sm row\"\n"},
{"SPLIT_MODE=tensor", "CTX=65536\nSPLIT_MODE=tensor\nEXTRA_ARGS=--n-cpu-moe 40\n"},
{"--n-cpu-ffn", "CTX=65536\nEXTRA_ARGS=\"--n-cpu-moe 40 --n-cpu-ffn 4\"\n"},
{"preset externe", extKeyFlag + "=1\n" + extKeyURL + "=https://api.example.com/v1\nEXTRA_ARGS=--n-cpu-moe 40\n"},
}
for _, r := range refus {
if _, _, err := autoPlacementPreset(r.content); err == nil {
t.Errorf("%s : accepté", r.name)
}
}
// --cpu-moe et -ngl / --tensor-split / --fit off retirés ensemble.
out, _, err = autoPlacementPreset("CTX=32768\nEXTRA_ARGS=\"-cmoe -ngl 99 -ts 1,1 --fit off -ub 512\"\n")
if err != nil {
t.Fatal(err)
}
if got := parseEnv(out)["EXTRA_ARGS"]; got != "--load-mode mmap" {
t.Errorf("EXTRA_ARGS = %q", got)
}
}
// presetSetKey : remplace, ajoute, retire — sans toucher aux voisines (BATCH
// n'est pas UBATCH) ni aux commentaires.
func TestPresetSetKey(t *testing.T) {
src := "# BATCH=1\nUBATCH=512\nBATCH=2048\n"
if got := presetSetKey(src, "BATCH", "4096"); got != "# BATCH=1\nUBATCH=512\nBATCH=4096\n" {
t.Errorf("remplacement : %q", got)
}
if got := presetSetKey(src, "NGL", "auto"); got != src+"NGL=auto\n" {
t.Errorf("ajout : %q", got)
}
if got := presetSetKey("A=1", "B", "2"); got != "A=1\nB=2\n" {
t.Errorf("ajout sans fin de ligne : %q", got)
}
if got := presetSetKey(src, "UBATCH", ""); got != "# BATCH=1\nBATCH=2048\n" {
t.Errorf("retrait : %q", got)
}
if got := presetSetKey("X=\"a b\"\n", "X", "c d"); parseEnv(got)["X"] != "c d" {
t.Errorf("guillemets : %q", got)
}
// Clé en double : parseEnv retient la dernière, toutes sont réécrites.
if got := presetSetKey("NGL=999\nCTX=1\nexport NGL=40\n", "NGL", "auto"); parseEnv(got)["NGL"] != "auto" {
t.Errorf("doublon : %q", got)
}
if got := presetSetKey("NGL=999\nCTX=1\nNGL=40", "NGL", ""); got != "CTX=1\n" {
t.Errorf("retrait des doublons : %q", got)
}
}
// joinArgs relu par splitArgs rend les mêmes arguments.
func TestJoinArgsRoundTrip(t *testing.T) {
args := []string{"--jinja", "--chat-template-file", "/mes modèles/tpl.jinja", "-ot", `blk\.(1|2)\.ffn=CPU`, "", "it's here"}
if got := splitArgs(joinArgs(args)); !reflect.DeepEqual(got, args) {
t.Errorf("aller-retour : %q", got)
}
}
// Le refus de LOAD_GUARD s'arrête avant tout chargement : l'interface doit le
// dire plutôt qu'un « chargement… » sans fin, même après un chargement réussi
// plus haut dans le journal.
func TestModelLoadErrorLoadGuard(t *testing.T) {
_, refuse := loadModeRisk(map[string]string{}, splitArgs("--load-mode none"),
serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10})
log := "llama_model_load: loading model\nmain: model loaded\nserver is listening\n[ERREUR] " + refuse + "\n"
if got := modelLoadErrorFrom(log); !strings.Contains(got, "LOAD_GUARD=off") {
t.Errorf("refus non signalé : %q", got)
}
// Une tentative suivante qui charge efface le refus.
if got := modelLoadErrorFrom(log + "loading model\nmodel loaded\n"); got != "" {
t.Errorf("refus ancien encore signalé : %q", got)
}
}
// Un refus de LOAD_GUARD ne doit pas être relancé en boucle : l'unité systemd
// écrite par « loki install » exclut son code de sortie, une unité d'avant ne
// le fait pas.
func TestUnitPreventsRefusalRestart(t *testing.T) {
for _, c := range []struct {
unit string
want bool
}{
{"[Service]\nRestart=on-failure\nRestartSec=3\n", false},
{"[Service]\nRestart=on-failure\nRestartPreventExitStatus=78\n", true},
{"[Service]\n RestartPreventExitStatus = 1 78 SIGKILL\n", true},
{"[Service]\nRestartPreventExitStatus=178\n", false},
{"[Service]\n# RestartPreventExitStatus=78\n", false},
} {
if got := unitPreventsRefusalRestart(c.unit); got != c.want {
t.Errorf("%q : %v, attendu %v", c.unit, got, c.want)
}
}
}
// Le refus sort avec son propre code (pas 1), y compris enveloppé ; une erreur
// ordinaire garde 1. Le moteur d'essai garde toujours le vrai code.
func TestLoadGuardRefusalExitStatus(t *testing.T) {
err := loadGuardRefusal("refus ; "+loadGuardMarker, true)
if got := exitStatusOf(err); got != loadGuardExitStatus {
t.Errorf("essai : code %d, attendu %d", got, loadGuardExitStatus)
}
if !strings.Contains(err.Error(), loadGuardMarker) {
t.Errorf("raison perdue : %q", err)
}
if got := exitStatusOf(fmt.Errorf("enveloppé : %w", err)); got != loadGuardExitStatus {
t.Errorf("enveloppé : code %d", got)
}
if got := exitStatusOf(errors.New("modèle introuvable")); got != 1 {
t.Errorf("erreur ordinaire : code %d, attendu 1", got)
}
// Hors superviseur (les tests ne tournent ni sous systemd ni sous launchd) :
// le vrai code aussi.
if restarts, _ := supervisorRestartsRefusal(); !restarts {
if got := exitStatusOf(loadGuardRefusal("refus", false)); got != loadGuardExitStatus {
t.Errorf("hors superviseur : code %d", got)
}
}
}
// Le refus envisagé sur la VRAM NVIDIA attend les cartes du moteur ; rien
// n'est lu sans refus en vue.
func TestLoadGuardNeedsDevices(t *testing.T) {
none := splitArgs("--load-mode none")
base := serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}
if !loadGuardNeedsDevices(map[string]string{}, none, base) {
t.Error("refus en vue sur nvidia-smi : cartes du moteur non demandées")
}
confirmed := base
confirmed.EngineVRAMMiB = 16 << 10
for name, c := range map[string]struct {
cfg map[string]string
args []string
si serveSysInfo
}{
"déjà lues": {map[string]string{}, none, confirmed},
"LOAD_GUARD=off": {map[string]string{"LOAD_GUARD": "off"}, none, base},
"mmap": {map[string]string{}, splitArgs("--load-mode mmap"), base},
"VRAM inconnue": {map[string]string{}, none, serveSysInfo{ModelBytes: 82 * gib, RAMMiB: 60 << 10}},
"loin du seuil": {map[string]string{}, none, serveSysInfo{ModelBytes: 30 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}},
} {
if loadGuardNeedsDevices(c.cfg, c.args, c.si) {
t.Errorf("%s : cartes demandées sans refus en vue", name)
}
}
}
// La VRAM des cartes du moteur : toutes additionnées, inconnue dès qu'une
// carte annonce 0 Mio (déjà pleine) ; devices.json relu en JSON (float64).
func TestEngineDevsVRAM(t *testing.T) {
mixed := splitDevsFrom([]map[string]any{
{"id": "Vulkan0", "total_mib": 12288},
{"id": "Vulkan1", "total_mib": float64(16368)},
})
if got := engineDevsVRAM(mixed); got != 12288+16368 {
t.Errorf("cartes mixtes : %d", got)
}
if got := engineDevsVRAM([]splitDev{{"CUDA0", 12288}, {"CUDA1", 0}}); got != 0 {
t.Errorf("carte à 0 Mio : %d, attendu inconnu", got)
}
if got := engineDevsVRAM(nil); got != 0 {
t.Errorf("liste vide : %d", got)
}
}
+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()))
}
}
}
+279
View File
@@ -0,0 +1,279 @@
package loki
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// Files de lancement CUDA (CUDA_SCALE_LAUNCH_QUEUES) sur deux GPU ou plus.
//
// Quand llama.cpp répartit les couches entre plusieurs cartes et que le modèle
// y tient en entier, il fait travailler les cartes en pipeline : pendant que la
// seconde calcule un micro-lot, la première attaque le suivant. Encore faut-il
// que le CPU puisse empiler assez de lancements CUDA d'avance ; avec la file par
// défaut du pilote, il bloque et le pipeline se vide. « 4x » quadruple cette
// file (PR llama.cpp #19042) : c'est une affaire de pilote, pas de calcul — mêmes
// noyaux, mêmes entrées, même ordre, donc une sortie identique au jeton près. Le
// prix : ~120 Mo de RAM système par GPU.
//
// llama.cpp l'a posé par défaut puis retiré (#19227) après des blocages sur
// Jetson (mémoire unifiée, MoE décodé en partie sur CPU) ; ses docs le
// recommandent désormais à la main en multi-GPU. Loki le pose donc lui-même,
// mais seulement là où le pipeline peut réellement exister (voir
// pipelineBlocker) : ailleurs le gain est nul et l'on ne s'exposerait qu'au
// seul cas de panne jamais rapporté.
//
// Gain non mesuré sur la machine de référence (5060 Ti + 3060, 27B) : en amont,
// +10 à 25 % de prompt seulement sur un 70B réparti sur deux grosses cartes,
// rien de mesurable sur un 8B. Le décodage ne bouge pas. Pour vérifier qu'il
// s'applique, le journal du moteur doit dire « pipeline parallelism enabled ».
//
// Choix de l'utilisateur, par ordre de priorité :
//
// CUDA_SCALE_LAUNCH_QUEUES déjà dans l'environnement → intouché (conteneur Unraid…)
// CUDA_LAUNCH_QUEUES=off → rien
// CUDA_LAUNCH_QUEUES=0.25x|0.5x|2x|4x → cette valeur, sans condition
// vide ou auto → 4x si ≥ 2 GPU et pipeline possible
//
// Une autre valeur serait lue comme 1x par le pilote, sans un mot : on l'ignore
// en le disant.
const launchQueuesAuto = "4x"
// launchQueueValues : les seules valeurs que le pilote CUDA comprend.
var launchQueueValues = map[string]bool{"0.25x": true, "0.5x": true, "2x": true, "4x": true}
// serveArgEnv : les variables LLAMA_ARG_* qui font, sans drapeau, ce que lit
// pipelineBlocker. llama.cpp les applique quand le drapeau manque — les ignorer
// ferait conclure à un pipeline que le moteur n'ouvrira pas.
var serveArgEnv = []string{"LLAMA_ARG_DEVICE", "LLAMA_ARG_SPLIT_MODE", "LLAMA_ARG_N_GPU_LAYERS",
"LLAMA_ARG_OVERRIDE_TENSOR", "LLAMA_ARG_CPU_MOE", "LLAMA_ARG_N_CPU_MOE", "LLAMA_ARG_N_CPU_FFN",
"LLAMA_ARG_KV_OFFLOAD", "LLAMA_ARG_NO_KV_OFFLOAD"}
// launchQueuesEnv décide de CUDA_SCALE_LAUNCH_QUEUES. Fonction pure : le nombre
// de GPU et l'environnement arrivent déjà sondés dans si. Valeur vide = ne rien
// poser.
func launchQueuesEnv(cfg map[string]string, extra []string, si serveSysInfo) (val string, notes []string) {
if si.LaunchQueues != "" {
return "", nil
}
want := strings.ToLower(strings.TrimSpace(cfg["CUDA_LAUNCH_QUEUES"]))
switch {
case want == "off":
return "", nil
case launchQueueValues[want]:
return want, []string{"CUDA_SCALE_LAUNCH_QUEUES=" + want + " (CUDA_LAUNCH_QUEUES)"}
case want != "" && want != "auto":
notes = append(notes, "CUDA_LAUNCH_QUEUES="+cfg["CUDA_LAUNCH_QUEUES"]+
" ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique")
}
n := servedGPUCount(extra, si)
if n < 2 || pipelineBlocker(cfg, extra, si.ArgEnv) != "" {
return "", notes
}
return launchQueuesAuto, append(notes, fmt.Sprintf("CUDA_SCALE_LAUNCH_QUEUES=%s (%d GPU, parallélisme pipeline "+
"possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", launchQueuesAuto, n))
}
// servedGPUCount : combien de GPU CUDA llama-server utilisera. Le --device du
// preset d'abord (c'est là que l'éditeur range le choix des cartes), puis ce
// que cmdServe a compté (CUDA_VISIBLE_DEVICES, sinon nvidia-smi). Un --device
// qui ne nomme que des cartes Vulkan ou Metal compte zéro : la variable ne
// concerne que CUDA.
func servedGPUCount(extra []string, si serveSysInfo) int {
dev := flagValue(extra, "-dev", "--device")
if dev == "" {
dev = si.ArgEnv["LLAMA_ARG_DEVICE"]
}
if dev == "" {
return si.GPUs
}
n := 0
for _, d := range strings.Split(dev, ",") {
if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(d)), "CUDA") {
n++
}
}
return n
}
// pipelineBlocker dit pourquoi llama.cpp n'ouvrira PAS le pipeline entre
// cartes (llama-context.cpp : toutes les couches sur GPU, découpe par couches,
// cache KV sur GPU, aucune surcharge de tenseurs). Vide = il peut l'ouvrir.
// --n-cpu-moe, --cpu-moe et --n-cpu-ffn sont des surcharges de tenseurs
// déguisées : le preset MoE à experts sur CPU n'a donc jamais de pipeline.
//
// Attention à la façon dont llama.cpp lit ces réglages (common/arg.cpp) : les
// variables LLAMA_ARG_* passent d'abord, la ligne de commande ensuite. Pour un
// réglage à valeur unique (-sm, -ngl, -kvo/-nkvo), la ligne de commande
// l'emporte ; mais les surcharges de tenseurs S'ACCUMULENT — variable et
// drapeaux, chaque occurrence ajoute les siennes. Un --n-cpu-moe 0 n'annule
// donc pas un LLAMA_ARG_N_CPU_MOE=30.
func pipelineBlocker(cfg map[string]string, extra []string, argEnv map[string]string) string {
if r := tensorOverride(extra, argEnv); r != "" {
return r
}
if !kvOffloaded(extra, argEnv) {
return "cache KV sur CPU"
}
sm := flagValue(extra, "-sm", "--split-mode")
if sm == "" {
sm = argEnv["LLAMA_ARG_SPLIT_MODE"]
}
if sm != "" && sm != "layer" {
return "découpe " + sm
}
// Couches GPU : celles d'EXTRA_ARGS, sinon NGL (que buildServeArgs traduit
// toujours en -ngl, sauf NGL=auto qui ne pose rien — la variable du moteur
// décide alors). Un nombre choisi — hors la sentinelle 999 — laisse en
// général des couches au CPU ; auto, all et 999 visent le tout-GPU.
ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
if ngl == "" {
ngl = strings.TrimSpace(cfg["NGL"])
if strings.EqualFold(ngl, "auto") {
ngl = argEnv["LLAMA_ARG_N_GPU_LAYERS"]
}
}
if ngl = strings.ToLower(strings.TrimSpace(ngl)); ngl != "" && ngl != "auto" && ngl != "all" && ngl != "999" {
return "couches GPU limitées à " + ngl
}
return ""
}
// tensorOverride dit si des tenseurs sont placés à la main (-ot et ses formes
// déguisées --cpu-moe, --n-cpu-moe, --n-cpu-ffn), en drapeau ou en variable.
// Vide = aucun. Le pipeline entre cartes s'y arrête, et --fit aussi
// (« tensor_buft_overrides already set by user, abort »).
func tensorOverride(extra []string, argEnv map[string]string) string {
if hasAnyFlag(extra, "-ot", "--override-tensor", "--cpu-moe", "-cmoe") ||
argEnv["LLAMA_ARG_OVERRIDE_TENSOR"] != "" || envTruthy(argEnv["LLAMA_ARG_CPU_MOE"]) {
return "surcharge de tenseurs"
}
for _, n := range []struct{ env, short, long string }{
{"LLAMA_ARG_N_CPU_MOE", "-ncmoe", "--n-cpu-moe"},
{"LLAMA_ARG_N_CPU_FFN", "-ncffn", "--n-cpu-ffn"},
} {
for _, v := range append(flagValues(extra, n.short, n.long), argEnv[n.env]) {
if v = strings.TrimSpace(v); v != "" && v != "0" {
return "couches " + n.long + " sur CPU"
}
}
}
return ""
}
// kvOffloaded : le cache KV reste-t-il sur GPU ? Le dernier -kvo/-nkvo de la
// ligne de commande tranche ; sinon LLAMA_ARG_NO_KV_OFFLOAD, dont la SEULE
// présence vaut « non » pour llama.cpp (quelle que soit sa valeur), puis
// LLAMA_ARG_KV_OFFLOAD lu comme un booléen.
func kvOffloaded(extra []string, argEnv map[string]string) bool {
on, set := true, false
for _, a := range extra {
name, _, _ := strings.Cut(a, "=")
switch name {
case "-kvo", "--kv-offload":
on, set = true, true
case "-nkvo", "--no-kv-offload":
on, set = false, true
}
}
if set {
return on
}
if argEnv["LLAMA_ARG_NO_KV_OFFLOAD"] != "" {
return false
}
switch strings.ToLower(strings.TrimSpace(argEnv["LLAMA_ARG_KV_OFFLOAD"])) {
case "off", "disabled", "false", "0":
return false
}
return true
}
// envTruthy lit un drapeau sans valeur passé par variable, comme llama.cpp :
// seuls on, enabled, true et 1 l'activent.
func envTruthy(v string) bool {
switch strings.TrimSpace(v) {
case "on", "enabled", "true", "1":
return true
}
return false
}
// flagValues : les valeurs de TOUTES les occurrences d'un drapeau (« -x 1 » ou
// « -x=1 »), pour ceux que llama.cpp cumule au lieu de garder le dernier.
func flagValues(args []string, flags ...string) []string {
var vals []string
for i, a := range args {
name, val, hasEq := strings.Cut(a, "=")
for _, f := range flags {
if name != f {
continue
}
if hasEq {
vals = append(vals, val)
} else if i+1 < len(args) {
vals = append(vals, args[i+1])
}
}
}
return vals
}
// probeServeGPUs remplit ce dont launchQueuesEnv a besoin. On ne sonde
// nvidia-smi que si la réponse peut servir : variable déjà posée, « off », un
// --device explicite ou CUDA_VISIBLE_DEVICES suffisent à trancher sans lui.
func probeServeGPUs(cfg map[string]string, extra []string, si *serveSysInfo) {
si.LaunchQueues = os.Getenv("CUDA_SCALE_LAUNCH_QUEUES")
si.ArgEnv = map[string]string{}
for _, k := range serveArgEnv {
if v, ok := os.LookupEnv(k); ok && v != "" {
si.ArgEnv[k] = v
} else if ok && k == "LLAMA_ARG_NO_KV_OFFLOAD" {
si.ArgEnv[k] = "1" // présente mais vide : llama.cpp n'y lit que la présence
}
}
if si.LaunchQueues != "" || strings.EqualFold(strings.TrimSpace(cfg["CUDA_LAUNCH_QUEUES"]), "off") {
return
}
if hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" {
return
}
// cmdServe a déjà posé la sélection de config.env dans l'environnement :
// os.Getenv voit donc le preset d'abord, le conteneur ensuite.
if cvd := strings.TrimSpace(os.Getenv("CUDA_VISIBLE_DEVICES")); cvd != "" {
for _, d := range strings.Split(cvd, ",") {
if strings.TrimSpace(d) != "" {
si.GPUs++
}
}
return
}
si.GPUs = nvidiaGPUCount(3 * time.Second)
}
// nvidiaGPUCount compte les cartes NVIDIA. Borné dans le temps : un pilote
// coincé peut faire pendre nvidia-smi, et le démarrage du moteur ne doit pas
// en dépendre. Au moindre doute, 0 — on ne pose rien.
func nvidiaGPUCount(timeout time.Duration) int {
if !hasTool("nvidia-smi") {
return 0
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
out, err := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index", "--format=csv,noheader")).Output()
if err != nil {
return 0
}
n := 0
for _, line := range strings.Split(string(out), "\n") {
if strings.TrimSpace(line) != "" {
n++
}
}
return n
}
+136
View File
@@ -0,0 +1,136 @@
package loki
import (
"reflect"
"testing"
)
// TestLaunchQueuesEnv : CUDA_SCALE_LAUNCH_QUEUES n'est posé que là où le
// pipeline entre cartes peut exister, et jamais par-dessus un choix de
// l'utilisateur.
func TestLaunchQueuesEnv(t *testing.T) {
two := serveSysInfo{GPUs: 2}
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want string
wantNotes int
}{
{name: "2 GPU, dense tout sur GPU : 4x, et on le dit",
cfg: map[string]string{"NGL": "999"}, si: two, want: "4x", wantNotes: 1},
{name: "NGL=all ou auto : pipeline possible",
cfg: map[string]string{"NGL": "all"}, si: two, want: "4x", wantNotes: 1},
{name: "1 GPU : rien",
cfg: map[string]string{"NGL": "999"}, si: serveSysInfo{GPUs: 1}},
{name: "nombre de GPU inconnu : rien",
cfg: map[string]string{}},
{name: "variable déjà posée par l'utilisateur : intouchée",
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "2x"}, si: serveSysInfo{GPUs: 2, LaunchQueues: "1x"}},
{name: "CUDA_LAUNCH_QUEUES=off : rien",
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "off"}, si: two},
{name: "valeur explicite valide : posée telle quelle, même sur 1 GPU",
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "2X"}, si: serveSysInfo{GPUs: 1}, want: "2x", wantNotes: 1},
{name: "valeur invalide : ignorée, dite, puis automatique",
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "8x"}, si: two, want: "4x", wantNotes: 2},
{name: "valeur invalide sur 1 GPU : seulement la note",
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "beaucoup"}, si: serveSysInfo{GPUs: 1}, wantNotes: 1},
{name: "preset MoE (-ot, --n-cpu-moe) sur 2 GPU : rien",
cfg: map[string]string{"EXTRA_ARGS": `-ot "per_layer_token_embd=CPU" --n-cpu-moe 40`}, si: two},
{name: "--n-cpu-moe seul : rien",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe=12"}, si: two},
{name: "--n-cpu-moe 0 : aucun expert sur CPU, pipeline possible",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 0"}, si: two, want: "4x", wantNotes: 1},
{name: "--cpu-moe : rien",
cfg: map[string]string{"EXTRA_ARGS": "--cpu-moe"}, si: two},
{name: "-sm row : rien",
cfg: map[string]string{"EXTRA_ARGS": "-sm row"}, si: two},
{name: "--split-mode=layer explicite : 4x",
cfg: map[string]string{"EXTRA_ARGS": "--split-mode=layer"}, si: two, want: "4x", wantNotes: 1},
{name: "NGL=28 : des couches restent au CPU, rien",
cfg: map[string]string{"NGL": "28"}, si: two},
{name: "-ngl 40 dans EXTRA_ARGS l'emporte sur NGL=999 : rien",
cfg: map[string]string{"NGL": "999", "EXTRA_ARGS": "-ngl 40"}, si: two},
{name: "cache KV sur CPU : rien",
cfg: map[string]string{"EXTRA_ARGS": "-nkvo"}, si: two},
{name: "--device d'une seule carte : rien",
cfg: map[string]string{"EXTRA_ARGS": "--device CUDA0"}, si: two},
{name: "--device de deux cartes CUDA, nvidia-smi non sondé : 4x",
cfg: map[string]string{"EXTRA_ARGS": "--device CUDA1,CUDA0 --tensor-split 0.6,0.4"}, want: "4x", wantNotes: 1},
{name: "--device Vulkan : la variable ne concerne pas ce moteur",
cfg: map[string]string{"EXTRA_ARGS": "-dev Vulkan0,Vulkan1"}, si: two},
{name: "--device none : rien",
cfg: map[string]string{"EXTRA_ARGS": "-dev none"}, si: two},
{name: "LLAMA_ARG_N_CPU_MOE dans l'environnement du moteur : rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_CPU_MOE": "30"}}},
{name: "LLAMA_ARG_SPLIT_MODE=none : rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_SPLIT_MODE": "none"}}},
{name: "NGL=auto et LLAMA_ARG_N_GPU_LAYERS=20 : rien",
cfg: map[string]string{"NGL": "auto"}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_GPU_LAYERS": "20"}}},
{name: "LLAMA_ARG_CPU_MOE=0 : désactivé, pipeline possible",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_CPU_MOE": "0"}}, want: "4x", wantNotes: 1},
{name: "LLAMA_ARG_DEVICE d'une seule carte : rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_DEVICE": "CUDA1"}}},
// Relecture : ce que llama.cpp fait vraiment de ses variables.
{name: "--n-cpu-moe 0 n'annule pas LLAMA_ARG_N_CPU_MOE (surcharges cumulées) : rien",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 0"},
si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_CPU_MOE": "30"}}},
{name: "deux --n-cpu-moe, le dernier à 0 : le premier compte quand même, rien",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 20 --n-cpu-moe 0"}, si: two},
{name: "--n-cpu-ffn (FFN dense sur CPU) : rien",
cfg: map[string]string{"EXTRA_ARGS": "-ncffn 8"}, si: two},
{name: "LLAMA_ARG_OVERRIDE_TENSOR : rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_OVERRIDE_TENSOR": "exps=CPU"}}},
{name: "LLAMA_ARG_CPU_MOE=yes : llama.cpp ne l'active pas, 4x",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_CPU_MOE": "yes"}}, want: "4x", wantNotes: 1},
{name: "LLAMA_ARG_NO_KV_OFFLOAD=0 : sa seule présence coupe l'offload, rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_NO_KV_OFFLOAD": "0"}}},
{name: "LLAMA_ARG_KV_OFFLOAD=false : rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_KV_OFFLOAD": "false"}}},
{name: "-kvo sur la ligne l'emporte sur LLAMA_ARG_NO_KV_OFFLOAD : 4x",
cfg: map[string]string{"EXTRA_ARGS": "-kvo"},
si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_NO_KV_OFFLOAD": "1"}}, want: "4x", wantNotes: 1},
{name: "-nkvo puis -kvo : le dernier gagne, 4x",
cfg: map[string]string{"EXTRA_ARGS": "-nkvo -kvo"}, si: two, want: "4x", wantNotes: 1},
{name: "NGL absent : Loki passe -ngl auto, LLAMA_ARG_N_GPU_LAYERS ne compte pas, 4x",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_GPU_LAYERS": "20"}}, want: "4x", wantNotes: 1},
{name: "NGL=Auto (casse libre, comme nglArgs) : 4x",
cfg: map[string]string{"NGL": "Auto"}, si: two, want: "4x", wantNotes: 1},
{name: "-sm tensor : pas de pipeline par couches, rien",
cfg: map[string]string{"EXTRA_ARGS": "-sm tensor"}, si: two},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, notes := launchQueuesEnv(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
if got != c.want {
t.Fatalf("valeur = %q, attendu %q", got, c.want)
}
if len(notes) != c.wantNotes {
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
}
})
}
}
// La variable passe par l'environnement : la ligne de commande ne bouge pas
// d'un octet, et la sélection GPU l'accompagne.
func TestBuildServeArgsLaunchQueues(t *testing.T) {
const bin, model = "/opt/llama/llama-server", "/models/Qwen3.6-27B-Q4_K_M.gguf"
cfg := map[string]string{"NGL": "999", "CUDA_VISIBLE_DEVICES": "1,0"}
si := serveSysInfo{Help: helpRecent, Model: model}
without, _, _ := buildServeArgs(cfg, nil, bin, si)
si.GPUs = 2
args, env, notes := buildServeArgs(cfg, nil, bin, si)
if !reflect.DeepEqual(args, without) {
t.Fatalf("la ligne de commande a changé\n got %q\nwant %q", args, without)
}
want := map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "CUDA_DEVICE_ORDER": "PCI_BUS_ID",
"CUDA_SCALE_LAUNCH_QUEUES": "4x"}
if !reflect.DeepEqual(env, want) {
t.Fatalf("env = %v, want %v", env, want)
}
// Une note pour 999 → auto, une pour les files de lancement.
if len(notes) != 2 {
t.Fatalf("notes = %q", notes)
}
}
+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)
}
}
}
+746
View File
@@ -0,0 +1,746 @@
package loki
import (
"encoding/json"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
bolt "go.etcd.io/bbolt"
)
// Décodage spéculatif géré par Loki : une tête MTP (prédiction de plusieurs
// jetons) intégrée au modèle ou publiée à part, ou un petit modèle brouillon.
//
// SPEC=off (défaut) → rien, même avec MODEL_DRAFT
// SPEC=auto → seulement si tout est réuni (voir specAutoBlocker)
// SPEC=mtp → imposé, sans les garde-fous d'auto, avec un avertissement
// MODEL_DRAFT=<nom> → la tête ou le brouillon, résolu comme MMPROJ
// SPEC_N_MAX=<n> → --spec-draft-n-max (défaut du moteur : 3)
// SPEC_SAMPLING=probabilistic → seulement avec SPEC=mtp ; greedy sinon
// SPEC=ngram → n-grammes tirés du contexte (ngram-mod), sans brouillon
// SPEC=mtp+ngram → les deux ; un n-gramme trouvé passe avant la tête MTP
//
// Rien n'y touche au modèle : chaque jeton émis est tiré par l'échantillonneur
// du modèle cible, un jeton du brouillon n'est gardé que s'il coïncide
// (common_sampler_sample_and_accept_n). Le prix est ailleurs : 1 à 2 Go de VRAM
// en plus, un prefill parfois plus lent, et une fonction très récente qui peut
// empêcher le moteur de démarrer. D'où l'opt-in, les garde-fous d'auto et le
// jeton de tentative (specAutoVerdict) : un lancement automatique qui n'a
// jamais répondu ne se retente pas avec la même configuration.
//
// Une vérification par lot n'emprunte pas les mêmes noyaux qu'un décodage jeton
// par jeton : même distribution, mais pas le même texte au bit près — deux
// candidats presque à égalité peuvent s'inverser à graine égale, comme entre
// prefill et décodage. On compare donc des sessions rejouées, pas des diffs.
const (
// specAutoMinBuild : premier build officiel où Loki ose l'auto (MTP serveur,
// PR #22673 et suites). Build inconnu = non : un compilé maison ou un fork
// peut connaître le drapeau sans le tenir.
specAutoMinBuild = 11009
specNMaxLimit = 64
// Bornes de ngram-mod, toujours écrites en clair : --spec-default pose les
// mêmes aujourd'hui, mais son code porte un TODO qui annonce d'autres types —
// une mise à jour du moteur changerait le réglage sans un mot. Un brouillon
// part dès 24 jetons qui se répètent mot pour mot, et fait 48 à 64 jetons :
// ce que recopie une boucle d'outils (chemins, diffs, arguments JSON).
specNgramNMatch = 24
specNgramNMin = 48
specNgramNMax = 64
// ggmlOffloadMinBatchDefault : seuil par défaut du moteur (ggml-cuda.cu) à
// partir duquel un lot recopie vers le GPU les poids restés en RAM.
ggmlOffloadMinBatchDefault = 32
)
// specArgEnv : les variables qui règlent la spéculation sans drapeau. Posées,
// elles valent un réglage d'EXTRA_ARGS : Loki se tait.
var specArgEnv = []string{"LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL", "LLAMA_ARG_SPEC_DRAFT_HF_REPO",
"LLAMA_ARG_SPEC_DRAFT_N_MAX", "LLAMA_ARG_SPEC_DRAFT_SAMPLING"}
// specUserFlags : la spéculation réglée à la main. Les ajouter EN PLUS ferait
// cumuler les types (--spec-type s'additionne, sans l'avertissement « specified
// multiple times » des autres drapeaux) ou charger deux brouillons.
var specUserFlags = []string{"--spec-type", "--spec_type", "-md", "--model-draft", "--spec-draft-model",
"-hfd", "-hfrd", "--spec-draft-hf", "--hf-repo-draft", "--spec-default"}
// specUserPrefixes : des réglages de n-grammes à la main (--spec-ngram-mod-n-max,
// --spec-ngram-simple-size-n…) disent la même chose qu'un --spec-type.
var specUserPrefixes = []string{"--spec-ngram-", "--spec_ngram_"}
// hasFlagPrefix : un drapeau d'args commence-t-il par l'un de ces préfixes ?
func hasFlagPrefix(args []string, prefixes ...string) string {
for _, a := range args {
for _, p := range prefixes {
if strings.HasPrefix(a, p) {
name, _, _ := strings.Cut(a, "=")
return name
}
}
}
return ""
}
// probeSpec : variables du moteur, puis MODEL_DRAFT résolu comme MMPROJ (nom
// simple cherché dans les dossiers déclarés, ou chemin absolu). Introuvable ne
// bloque PAS le lancement, contrairement au projecteur : le brouillon n'est
// qu'une accélération, le moteur démarre sans et la note dit pourquoi.
func probeSpec(cfg map[string]string, si *serveSysInfo) {
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range specArgEnv {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
d := strings.TrimSpace(cfg["MODEL_DRAFT"])
if d == "" {
return
}
p, err := resolveServeModelPath(d)
if err == nil {
_, err = os.Stat(p)
}
if err != nil {
si.DraftErr = err.Error()
return
}
si.Draft = p
if a, err := os.Stat(p); err == nil {
if b, err := os.Stat(si.Model); err == nil && os.SameFile(a, b) {
si.DraftIsModel = true
}
}
if g, err := ggufMeta(p); err == nil {
si.DraftGGUF = &g
}
}
// specSidecarArch : têtes de spéculation qui ne sont pas des modèles autonomes
// (llama.cpp : draft-eagle3, draft-dflash/dspark). Un -md vers elles avec
// draft-simple ne démarrerait pas : leur type se règle à la main.
func specSidecarArch(arch string) bool {
switch strings.ToLower(strings.TrimSpace(arch)) {
case "eagle3", "dflash":
return true
}
return false
}
// specMode lit SPEC : "off", "auto", "mtp", "ngram", "mtp+ngram", ou "" si
// illisible.
func specMode(cfg map[string]string) string {
switch v := strings.ToLower(strings.TrimSpace(cfg["SPEC"])); v {
case "", "off", "non", "no", "0":
return "off"
case "auto", "mtp", "ngram", "mtp+ngram":
return v
case "ngram+mtp":
return "mtp+ngram"
}
return ""
}
// specUserSet : EXTRA_ARGS ou l'environnement règlent déjà la spéculation.
func specUserSet(extra []string, argEnv map[string]string) bool {
if hasAnyFlag(extra, specUserFlags...) || hasFlagPrefix(extra, specUserPrefixes...) != "" {
return true
}
for _, k := range []string{"LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL", "LLAMA_ARG_SPEC_DRAFT_HF_REPO"} {
if argEnv[k] != "" {
return true
}
}
return false
}
// helpSupportsMTP : le moteur connaît le type draft-mtp ET le drapeau actuel
// du nombre de jetons — un moteur qui n'a que l'ancien --draft-max est d'une
// autre génération, où un MTP n'existe pas.
func helpSupportsMTP(help string) bool {
return strings.Contains(help, "draft-mtp") && strings.Contains(help, "--spec-draft-n-max")
}
// ctxFixed : le contexte que verra le moteur (EXTRA_ARGS, sinon CTX, sinon le
// défaut de buildServeArgs), et s'il est chiffré. Avec 0 ou une valeur
// illisible, --fit a le droit de RÉDUIRE le contexte pour tenir.
func ctxFixed(cfg map[string]string, extra []string) (string, bool) {
c := flagValue(extra, "-c", "--ctx-size")
if c == "" {
c = strings.TrimSpace(cfg["CTX"])
}
if c == "" {
c = "32768"
}
n, err := strconv.Atoi(c)
return c, err == nil && n > 0
}
// statefulSampler : un échantillonneur dont l'état avance à chaque tirage
// (mirostat, adaptive-p). Le rejet probabiliste fait d'abord tirer le modèle
// cible, puis peut émettre un autre jeton : ces échantillonneurs avanceraient
// sur un jeton que la réponse ne contient pas. Le mode greedy, lui, reste exact.
func statefulSampler(extra []string) string {
for _, v := range flagValues(extra, "--mirostat") {
if v = strings.TrimSpace(v); v != "" && v != "0" {
return "--mirostat " + v
}
}
for _, v := range flagValues(extra, "--samplers") {
if strings.Contains(strings.ToLower(strings.ReplaceAll(v, "-", "_")), "adaptive_p") {
return "--samplers adaptive-p"
}
}
for _, v := range flagValues(extra, "--sampling-seq", "--sampler-seq") {
if strings.Contains(v, "a") {
return "--sampling-seq avec adaptive-p"
}
}
return ""
}
// specAutoBlocker dit pourquoi SPEC=auto ne s'active pas, ou vide. Chaque
// garde répond à une panne précise :
//
// - qwen4exp : MTP fusionné il y a quelques jours, OOM signalés en multi-GPU ;
// - placement manuel (couches fixées, -ts, -ot, experts sur CPU, -sm row,
// --fit off, -dev) : seul --fit compte la VRAM de la tête MTP ;
// - contexte non chiffré : fit aurait le droit de le RÉDUIRE pour faire de
// la place — de l'information perdue pour le modèle ;
// - projecteur vision : une VRAM déjà serrée par un second modèle ;
// - build officiel inconnu ou trop ancien ;
// - un essai précédent avec la même configuration n'a jamais répondu.
func specAutoBlocker(cfg map[string]string, extra []string, si serveSysInfo) string {
if si.GGUF != nil && strings.EqualFold(si.GGUF.Arch, "qwen4exp") {
return "qwen4exp : MTP trop récent pour ce moteur, SPEC=mtp pour l'imposer"
}
if why := fitBlocker(cfg, extra, si); why != "" {
return "placement manuel (" + why + ") — --fit ne compte pas la VRAM du brouillon"
}
if hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" {
return "placement manuel (--device) — --fit ne compte pas la VRAM du brouillon"
}
if _, ok := ctxFixed(cfg, extra); !ok {
return "contexte non chiffré — fit pourrait le réduire pour loger le brouillon"
}
if si.MMProj != "" || hasAnyFlag(extra, "--mmproj", "-mm") {
return "projecteur vision chargé"
}
if si.EngineBuild < specAutoMinBuild {
if si.EngineBuild <= 0 {
return "build du moteur inconnu (compilé ou fork)"
}
return fmt.Sprintf("moteur b%d, b%d au moins", si.EngineBuild, specAutoMinBuild)
}
return si.SpecAutoBlocked
}
// specArgs : drapeaux de décodage spéculatif que Loki ajoute, ce qu'il en dit,
// et s'ils viennent de SPEC=auto (cmdServe pose alors le jeton de tentative).
// Fonction pure ; cmdServe a déjà résolu MODEL_DRAFT et lu les GGUF.
func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string, auto bool) {
// MODEL_DRAFT = le modèle principal lui-même (choisi dans la liste des
// brouillons de l'éditeur) : -md recharge alors les 27B une seconde fois
// (common_speculative_init_result charge tout fichier -md comme un modèle
// à part), quand la tête MTP intégrée se greffe sur le modèle déjà en VRAM.
// Vécu : « allocating 9628.34 MiB on device 0 » pour le brouillon, moteur
// mort ; ou, quand ça passait, des couches renvoyées sur le CPU et 5 t/s au
// lieu de 22. On l'ignore : même tête, sans les poids en double.
if si.DraftIsModel && strings.TrimSpace(cfg["MODEL_DRAFT"]) != "" {
c := make(map[string]string, len(cfg))
for k, v := range cfg {
c[k] = v
}
delete(c, "MODEL_DRAFT")
args, notes, auto = specArgs(c, extra, si)
return args, append([]string{"MODEL_DRAFT est le modèle principal lui-même : ignoré — la tête MTP intégrée " +
"sert de brouillon sans recharger les poids (retire MODEL_DRAFT du preset)"}, notes...), auto
}
mode := specMode(cfg)
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
switch mode {
case "":
return nil, []string{"SPEC=" + cfg["SPEC"] + " illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif"}, false
case "off":
if draftKey != "" {
return nil, []string{"MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)"}, false
}
return nil, nil, false
}
if specUserSet(extra, si.ArgEnv) {
return nil, []string{"décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré"}, false
}
skip := func(why string) ([]string, []string, bool) {
return nil, []string{"SPEC=" + mode + " : " + why + " — sans décodage spéculatif"}, false
}
// N-grammes : leurs garde-fous d'abord, ils valent pour les deux formes.
if mode == "ngram" || mode == "mtp+ngram" {
if why := ngramBlocker(cfg, extra, si, mode); why != "" {
return skip(why)
}
}
if mode == "ngram" {
args, notes = ngramSpecArgs(cfg, extra, si, "")
return args, notes, false
}
src, mtp, why, quiet := specSource(mode, draftKey, si)
if mode == "mtp+ngram" && !mtp {
// Pas de tête MTP utilisable : la passer quand même ferait mourir le
// moteur sur « failed to create MTP context », et boucler. Les n-grammes,
// eux, n'ont besoin de rien.
if why == "" {
why = "MODEL_DRAFT n'est pas une tête MTP"
}
args, notes = ngramSpecArgs(cfg, extra, si, "SPEC=mtp+ngram : sans MTP ("+why+") → n-grammes seuls. ")
return args, notes, false
}
if why != "" {
return skip(why)
}
if quiet {
return nil, nil, false
}
args = src
if mode == "mtp+ngram" {
// Une seule liste : --spec-type s'additionne, mais une valeur unique se
// lit d'un coup d'œil dans le journal.
args[len(args)-1] = "draft-mtp,ngram-mod"
args = append(args, ngramModParams()...)
}
label := "brouillon " + baseName(si.Draft)
if mtp {
label = "tête MTP"
}
if mode == "auto" {
if why := specAutoBlocker(cfg, extra, si); why != "" {
return skip(why)
}
auto = true
notes = append(notes, "SPEC=auto → "+label+" : sortie inchangée (chaque jeton est vérifié par le modèle), "+
"~1-2 Go de VRAM en plus. Mesure le prefill ; SPEC=off pour couper.")
} else {
note := "SPEC=" + mode + " → " + label + " imposé : sortie inchangée, ~1-2 Go de VRAM en plus"
if mode == "mtp+ngram" {
note += " ; n-grammes en plus (ngram-mod, prioritaires quand ils trouvent une répétition)"
}
if why := fitBlocker(cfg, extra, si); why != "" || hasAnyFlag(extra, "-dev", "--device") {
if why == "" {
why = "--device"
}
note += " ; placement manuel (" + why + ") : --fit ne compte pas le brouillon, " +
"place-le avec -devd / -ngld si la VRAM manque"
}
if _, ok := ctxFixed(cfg, extra); !ok {
note += " ; contexte non chiffré : fit pourrait le réduire pour loger le brouillon"
}
if si.GGUF != nil && strings.EqualFold(si.GGUF.Arch, "qwen4exp") {
note += " ; MTP qwen4exp très récent"
}
notes = append(notes, note)
if mode == "mtp+ngram" {
notes = append(notes, ngramCostNotes(cfg, extra, si)...)
}
}
// Nombre de jetons anticipés : défaut du moteur (3) si la clé est vide.
if v := strings.TrimSpace(cfg["SPEC_N_MAX"]); v != "" {
n, err := strconv.Atoi(v)
switch {
case err != nil || n < 1 || n > specNMaxLimit:
notes = append(notes, "SPEC_N_MAX="+v+" illisible (1 à 64 jetons) : défaut du moteur")
case !strings.Contains(si.Help, "--spec-draft-n-max"):
notes = append(notes, "ce moteur ne connaît pas --spec-draft-n-max : SPEC_N_MAX ignoré")
case hasAnyFlag(extra, "--spec-draft-n-max") || si.ArgEnv["LLAMA_ARG_SPEC_DRAFT_N_MAX"] != "":
default:
args = append(args, "--spec-draft-n-max", strconv.Itoa(n))
}
}
// Tirage du brouillon. greedy est exact quelle que soit la chaîne
// d'échantillonnage : on le fixe dès que le moteur connaît le drapeau, pour
// qu'un changement de défaut en amont ne change rien ici.
sampling := "greedy"
switch v := strings.ToLower(strings.TrimSpace(cfg["SPEC_SAMPLING"])); v {
case "", "greedy":
case "probabilistic":
switch {
case mode == "auto":
notes = append(notes, "SPEC_SAMPLING=probabilistic ignoré avec SPEC=auto (SPEC=mtp pour le choisir) : greedy")
case statefulSampler(extra) != "":
notes = append(notes, "SPEC_SAMPLING=probabilistic refusé avec "+statefulSampler(extra)+
" (cet échantillonneur avancerait sur des jetons rejetés) : greedy")
case !strings.Contains(si.Help, "probabilistic"):
notes = append(notes, "ce moteur ne connaît pas --spec-draft-sampling probabilistic : greedy")
default:
sampling = v
}
default:
notes = append(notes, "SPEC_SAMPLING="+v+" illisible (greedy ou probabilistic) : greedy")
}
if strings.Contains(si.Help, "--spec-draft-sampling") && !hasAnyFlag(extra, "--spec-draft-sampling") &&
si.ArgEnv["LLAMA_ARG_SPEC_DRAFT_SAMPLING"] == "" {
args = append(args, "--spec-draft-sampling", sampling)
}
return args, notes, auto
}
// specSource : d'où vient le brouillon de SPEC=auto|mtp|mtp+ngram. MODEL_DRAFT
// s'il est posé, sinon la tête MTP du modèle ; dans les deux cas on regarde les
// TENSEURS, comme llama.cpp : une clé nextn_predict_layers seule ne prouve pas
// que la tête est dans le fichier. why non vide = aucun brouillon ; quiet =
// rien à dire (auto sur un modèle sans MTP).
func specSource(mode, draftKey string, si serveSysInfo) (args []string, mtp bool, why string, quiet bool) {
switch {
case draftKey != "":
switch {
case si.Draft == "":
why := "MODEL_DRAFT=" + draftKey + " introuvable"
if si.DraftErr != "" {
why += " (" + si.DraftErr + ")"
}
return nil, false, why, false
case si.DraftGGUF == nil:
return nil, false, "MODEL_DRAFT illisible (GGUF incomplet ou en cours de téléchargement ?)", false
case si.DraftGGUF.HasNextNTensor:
if !helpSupportsMTP(si.Help) {
return nil, false, "ce moteur ne connaît pas draft-mtp", false
}
// Type explicite : llama.cpp ne le devine que sur la première tranche.
return []string{"-md", si.Draft, "--spec-type", "draft-mtp"}, true, "", false
case specSidecarArch(si.DraftGGUF.Arch):
return nil, false, "MODEL_DRAFT est une tête " + si.DraftGGUF.Arch +
", pas un modèle brouillon : règle -md et --spec-type dans EXTRA_ARGS", false
case mode == "mtp+ngram":
return nil, false, "", false // un modèle brouillon n'est pas une tête MTP
default:
if !strings.Contains(si.Help, "--spec-type") || !strings.Contains(si.Help, "draft-simple") {
return nil, false, "ce moteur ne connaît pas --spec-type draft-simple", false
}
// Sans type, un brouillon qui n'est pas une tête MTP serait chargé en
// VRAM puis jamais utilisé.
return []string{"-md", si.Draft, "--spec-type", "draft-simple"}, false, "", false
}
case si.GGUF == nil:
return nil, false, "métadonnées du modèle illisibles", false
case !si.GGUF.HasNextNTensor:
if mode != "auto" || si.GGUF.NextN > 0 {
return nil, false, "pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)", false
}
return nil, false, "", true
case !helpSupportsMTP(si.Help):
return nil, false, "ce moteur ne connaît pas draft-mtp", false
}
return []string{"--spec-type", "draft-mtp"}, true, "", false
}
// ngramModParams : les trois bornes de ngram-mod, toujours explicites.
func ngramModParams() []string {
return []string{
"--spec-ngram-mod-n-match", strconv.Itoa(specNgramNMatch),
"--spec-ngram-mod-n-min", strconv.Itoa(specNgramNMin),
"--spec-ngram-mod-n-max", strconv.Itoa(specNgramNMax),
}
}
// ngramBlocker dit pourquoi SPEC=ngram|mtp+ngram ne s'active pas, ou vide.
//
// - Le moteur doit connaître --spec-ngram-mod-n-match, pas seulement le mot
// « ngram-mod » : les moteurs d'avant le renommage ont encore le type dans
// leur liste mais refusent les drapeaux actuels, et meurent au démarrage.
// - SPEC=ngram n'a pas de brouillon : un --spec-draft-* d'EXTRA_ARGS veut dire
// qu'on règle un brouillon à la main, on ne mélange pas.
// - Poids en RAM (experts MoE sur CPU…) : llama.cpp recopie vers le GPU les
// poids d'un lot dès GGML_OP_OFFLOAD_MIN_BATCH jetons (32 par défaut). Un
// lot de vérification de n_max+1 jetons au-dessus du seuil ferait passer
// les experts de chaque couche par le PCIe — voire par le disque si le
// modèle mappé dépasse la RAM — à chaque brouillon. Refusé tant que le seuil
// n'est pas relevé au-dessus du lot.
func ngramBlocker(cfg map[string]string, extra []string, si serveSysInfo, mode string) string {
if !strings.Contains(si.Help, "--spec-ngram-mod-n-match") {
return "ce moteur ne connaît pas --spec-ngram-mod-n-match"
}
if mode == "ngram" {
if f := hasFlagPrefix(extra, "--spec-draft-", "--spec_draft_"); f != "" {
return f + " dans EXTRA_ARGS (brouillon réglé à la main)"
}
}
blocks := 0
if si.GGUF != nil {
blocks = si.GGUF.BlockCount
}
if r := cpuWeights(cfg, extra, si.ArgEnv, blocks); r != "" {
if thr := opOffloadThreshold(cfg, si); specNgramNMax+1 >= thr {
return fmt.Sprintf("%s : un lot de vérification de %d jetons atteint GGML_OP_OFFLOAD_MIN_BATCH (%d) et "+
"recopierait ces poids vers le GPU à chaque brouillon — OP_OFFLOAD_MIN_BATCH=128 pour l'essayer, à mesurer",
r, specNgramNMax+1, thr)
}
}
return ""
}
// opOffloadThreshold : le seuil GGML_OP_OFFLOAD_MIN_BATCH que verra le moteur —
// celui de l'environnement, sinon celui d'OP_OFFLOAD_MIN_BATCH, sinon le défaut.
func opOffloadThreshold(cfg map[string]string, si serveSysInfo) int {
v := strings.TrimSpace(si.UserEnv["GGML_OP_OFFLOAD_MIN_BATCH"])
if v == "" {
v, _ = opOffloadMinBatchEnv(cfg, si)
}
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
return ggmlOffloadMinBatchDefault
}
// ngramSpecArgs : SPEC=ngram, ou le repli de mtp+ngram sans tête MTP (lead dit
// pourquoi). Pas de --spec-draft-* : il n'y a pas de brouillon.
func ngramSpecArgs(cfg map[string]string, extra []string, si serveSysInfo, lead string) (args, notes []string) {
args = append([]string{"--spec-type", "ngram-mod"}, ngramModParams()...)
notes = append(notes, lead+fmt.Sprintf("SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de %d à %d jetons) : "+
"distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer "+
"(acceptation par nature dans /api/perf/summary)", specNgramNMin, specNgramNMax))
if lead == "" {
// --spec-type explicite : llama.cpp ne choisit plus de lui-même la tête MTP.
switch {
case strings.TrimSpace(cfg["MODEL_DRAFT"]) != "":
notes = append(notes, "MODEL_DRAFT ignoré avec SPEC=ngram : SPEC=mtp+ngram pour les deux")
case si.GGUF != nil && si.GGUF.HasNextNTensor:
notes = append(notes, "tête MTP du modèle inutilisée avec SPEC=ngram : SPEC=mtp+ngram pour les deux")
}
}
if strings.TrimSpace(cfg["SPEC_N_MAX"]) != "" || strings.TrimSpace(cfg["SPEC_SAMPLING"]) != "" {
notes = append(notes, "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler")
}
return args, append(notes, ngramCostNotes(cfg, extra, si)...)
}
// ngramCostNotes : ce que les longs brouillons coûtent, là où ça se voit.
func ngramCostNotes(cfg map[string]string, extra []string, si serveSysInfo) []string {
var notes []string
if si.GGUF != nil && si.GGUF.Hybrid {
notes = append(notes, "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM "+
"hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit")
}
blocks := 0
if si.GGUF != nil {
blocks = si.GGUF.BlockCount
}
if cpuWeights(cfg, extra, si.ArgEnv, blocks) != "" && si.ModelBytes > 0 && si.RAMMiB > 0 &&
si.ModelBytes > si.RAMMiB<<20 {
notes = append(notes, "modèle plus gros que la RAM, poids sur CPU : un lot de vérification lit la plupart des "+
"experts de chaque couche — surveille les lectures disque, pas seulement les jetons/s")
}
if nglForced(cfg, extra) {
notes = append(notes, fmt.Sprintf("NGL imposé (--fit inactif) : les logits de %d positions par slot prennent "+
"quelques dizaines de Mio de VRAM en plus", specNgramNMax+1))
}
return notes
}
// --- Jeton de tentative ------------------------------------------------------
//
// Un essai automatique qui fait tomber le moteur ne doit pas se rejouer : sous
// systemd il redémarrerait en boucle en rechargeant 15 Go à chaque fois. cmdServe
// ne peut pas constater l'échec lui-même (sous Unix il DEVIENT llama-server).
// Il pose donc un jeton avant de lancer ; le process web l'efface dès que le
// moteur répond. Au lancement suivant, un jeton encore là pour la même
// configuration, le même moteur et le même build veut dire « jamais répondu » :
// l'auto est coupé pour cette combinaison, et on le dit. Une mise à jour du
// moteur ou un preset modifié retente. Sans process web (CLI seule), le jeton
// n'est jamais effacé : on retombe sur off, le côté sûr.
const (
specAttemptKey = "spec_auto_attempt"
specFailedKey = "spec_auto_failed"
specFailedMax = 32
)
// specAutoMark identifie une combinaison preset × moteur.
type specAutoMark struct {
FP string `json:"fp"`
Bin string `json:"bin"`
Build int `json:"build"`
}
func (m specAutoMark) key() string { return fmt.Sprintf("%s|%s|%d", m.FP, m.Bin, m.Build) }
// specAutoVerdict : pure. Renvoie la raison de couper l'auto (vide = permis)
// et s'il faut inscrire cur parmi les échecs.
func specAutoVerdict(cur specAutoMark, attempt *specAutoMark, failed map[string]string) (why string, record bool) {
if w := failed[cur.key()]; w != "" {
return w, false
}
if attempt != nil && attempt.key() == cur.key() {
return "le dernier lancement avec cette configuration n'a jamais répondu", true
}
return "", false
}
// specAutoPeek lit l'état sans rien changer : la raison de couper l'auto (vide
// = permis) et s'il faudra inscrire l'échec. Rien n'est consommé avant que le
// port soit libre (specAutoSettle) : un second « loki serve », refusé parce que
// le premier charge encore, ne doit pas prendre le jeton de celui-ci pour un
// échec.
func specAutoPeek(cur specAutoMark) (why string, record bool) {
_ = view(bkState, func(b *bolt.Bucket) error {
attempt, failed := readSpecAuto(b)
why, record = specAutoVerdict(cur, attempt, failed)
return nil
})
return why, record
}
// specAutoSettle range, port libre, juste avant de lancer le moteur : l'ancien
// jeton est consommé, l'échec inscrit s'il y a lieu (record, why), et un
// nouveau jeton posé si ce lancement ajoute des drapeaux automatiques.
func specAutoSettle(cur specAutoMark, why string, record, attempt bool) {
_ = update(bkState, func(b *bolt.Bucket) error {
_, failed := readSpecAuto(b)
_ = b.Delete([]byte(specAttemptKey))
if record {
if err := putFailed(b, failed, cur.key(), why); err != nil {
return err
}
}
if !attempt {
return nil
}
raw, err := json.Marshal(cur)
if err != nil {
return err
}
return b.Put([]byte(specAttemptKey), raw)
})
}
// readSpecAuto : le jeton en cours (nil = aucun) et les échecs inscrits.
func readSpecAuto(b *bolt.Bucket) (attempt *specAutoMark, failed map[string]string) {
if raw := b.Get([]byte(specAttemptKey)); raw != nil {
var a specAutoMark
if json.Unmarshal(raw, &a) == nil {
attempt = &a
}
}
failed = map[string]string{}
if raw := b.Get([]byte(specFailedKey)); raw != nil {
_ = json.Unmarshal(raw, &failed)
}
return attempt, failed
}
func putFailed(b *bolt.Bucket, failed map[string]string, key, why string) error {
if len(failed) >= specFailedMax {
failed = map[string]string{}
}
failed[key] = why
raw, err := json.Marshal(failed)
if err != nil {
return err
}
return b.Put([]byte(specFailedKey), raw)
}
// --- Côté process web --------------------------------------------------------
var (
specWatchOnce sync.Once
offloadedRe = regexp.MustCompile(`offloaded (\d+)/(\d+) layers to GPU`)
)
// startSpecAttemptWatch : une goroutine qui efface le jeton dès que le moteur
// répond, sans attendre qu'une page interroge /api/status.
func startSpecAttemptWatch() {
specWatchOnce.Do(func() {
go func() {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for range t.C {
specAttemptTick()
}
}()
})
}
func specAttemptTick() {
var a specAutoMark
if !getJSON(bkState, specAttemptKey, &a) {
return
}
if externalActive() {
specAttemptClear(a, "") // plus de moteur local à attendre
return
}
if !healthCheck() {
return
}
// Répondu. Mais avec -c fixé, fit ne réduit pas le contexte : s'il manque
// la place du brouillon, il descend des couches en RAM — le moteur tourne,
// plus lentement que sans MTP. Ça compte aussi comme un échec de l'auto.
why := ""
if n, m := lastOffload(serviceLogTail(600)); m > 0 && n < m {
why = fmt.Sprintf("seulement %d/%d couches sur GPU avec le brouillon : fit a déplacé des couches en RAM", n, m)
fmt.Println("[loki] SPEC=auto : " + why + " — coupé au prochain démarrage (SPEC=mtp pour l'imposer)")
}
specAttemptClear(a, why)
}
// specAttemptClear efface le jeton a — et lui seul : relu sous verrou, car un
// « loki serve » a pu entre-temps poser celui d'un AUTRE lancement, qui n'a pas
// encore répondu. why non vide inscrit l'échec de a.
func specAttemptClear(a specAutoMark, why string) {
_ = update(bkState, func(b *bolt.Bucket) error {
cur, failed := readSpecAuto(b)
if cur == nil || cur.key() != a.key() {
return nil
}
_ = b.Delete([]byte(specAttemptKey))
if why == "" {
return nil
}
return putFailed(b, failed, a.key(), why)
})
}
// lastOffload lit les lignes « offloaded N/M layers to GPU » du DERNIER
// chargement du journal, repéré à la ligne du serveur « loading model
// '<chemin>' ». Pas à « load_model » (le serveur écrit encore « load_model:
// initializing… » APRÈS le chargement) ni à « loading model tensors » (écrit
// aussi pour le brouillon, qui masquerait le modèle). Un brouillon chargé à
// part a sa propre ligne : la pire des deux compte. 0, 0 = introuvable : on ne
// conclut rien.
func lastOffload(log string) (n, m int) {
lines := strings.Split(log, "\n")
start := -1
for i, l := range lines {
if strings.Contains(l, "loading model '") {
start = i
}
}
if start < 0 {
return 0, 0
}
for _, l := range lines[start:] {
s := offloadedRe.FindStringSubmatch(l)
if s == nil {
continue
}
a, _ := strconv.Atoi(s[1])
b, _ := strconv.Atoi(s[2])
if m == 0 || a < b {
n, m = a, b
}
if a < b {
break
}
}
return n, m
}
+526
View File
@@ -0,0 +1,526 @@
package loki
import (
"os"
"reflect"
"slices"
"strings"
"testing"
)
const helpSpec = helpRecent + `--spec-draft-n-max N number of tokens to draft for speculative decoding (default: 3)
--spec-draft-sampling {greedy,probabilistic}
how the draft is sampled (default: greedy)
--spec-type [none,ngram-cache,ngram-simple,ngram-map-k,ngram-map-k4v,ngram-mod,draft-simple,draft-eagle3,draft-mtp,draft-dflash,draft-dspark]
comma-separated list of types of speculative decoding to use (default: none)
`
// helpSpecOld : un moteur qui connaît --spec-type mais pas encore le MTP.
const helpSpecOld = helpRecent + `--spec-type [none,ngram-cache,ngram-simple,draft-simple]
`
// mtpReady : un Qwen3.6 27B avec sa tête MTP dans le fichier, placé par --fit,
// sur un moteur officiel récent. Le cas où SPEC=auto a le droit d'agir.
func mtpReady() serveSysInfo {
return serveSysInfo{
Help: helpSpec,
Model: "/models/Qwen3.6-27B-MTP.gguf",
GGUF: &GGUFInfo{Arch: "qwen35", BlockCount: 65, NextN: 1, HasNextNTensor: true, Hybrid: true},
ArgEnv: map[string]string{},
EngineBuild: 11351,
}
}
func TestSpecArgs(t *testing.T) {
mtp := []string{"--spec-type", "draft-mtp", "--spec-draft-sampling", "greedy"}
auto := map[string]string{"SPEC": "auto"}
forced := map[string]string{"SPEC": "mtp"}
with := func(base map[string]string, kv ...string) map[string]string {
m := map[string]string{}
for k, v := range base {
m[k] = v
}
for i := 0; i+1 < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
}
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
wantAuto bool
notes int
}{
{name: "SPEC absent : rien, même sur un modèle MTP (opt-in)", cfg: map[string]string{}},
{name: "SPEC=off : rien", cfg: map[string]string{"SPEC": "off"}},
{name: "SPEC=off avec MODEL_DRAFT : rien, et on le dit",
cfg: map[string]string{"SPEC": "off", "MODEL_DRAFT": "mtp-Qwen3.6-27B.gguf"}, notes: 1},
{name: "SPEC illisible : rien, et on le dit", cfg: map[string]string{"SPEC": "turbo"}, notes: 1},
{name: "auto, tête MTP dans le fichier, --fit actif : draft-mtp", cfg: auto, want: mtp, wantAuto: true, notes: 1},
{name: "auto, clé nextn sans tenseur (tête publiée à part) : rien",
cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.GGUF.HasNextNTensor = false }},
{name: "auto, modèle sans MTP : rien, sans bruit",
cfg: auto, si: func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "llama", BlockCount: 32} }},
{name: "auto, métadonnées illisibles : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.GGUF = nil }},
{name: "--spec-type dans EXTRA_ARGS : Loki se tait", cfg: auto, extra: []string{"--spec-type", "ngram-mod"}, notes: 1},
{name: "--model-draft=… dans EXTRA_ARGS : Loki se tait", cfg: auto, extra: []string{"--model-draft=d.gguf"}, notes: 1},
{name: "-hfd dans EXTRA_ARGS : Loki se tait", cfg: auto, extra: []string{"-hfd", "u/r"}, notes: 1},
{name: "--spec-default dans EXTRA_ARGS : Loki se tait", cfg: auto, extra: []string{"--spec-default"}, notes: 1},
{name: "LLAMA_ARG_SPEC_TYPE posée : Loki se tait", cfg: auto, notes: 1,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SPEC_TYPE"] = "draft-mtp" }},
{name: "auto, --tensor-split : rien", cfg: auto, extra: []string{"--tensor-split", "0.965,0.035"}, notes: 1},
{name: "auto, -ts=… : rien", cfg: auto, extra: []string{"-ts=1,1"}, notes: 1},
{name: "auto, NGL chiffré : rien", cfg: with(auto, "NGL", "40"), notes: 1},
{name: "auto, -ngl 99 dans EXTRA_ARGS : rien", cfg: auto, extra: []string{"-ngl", "99"}, notes: 1},
{name: "auto, -ot : rien", cfg: auto, extra: []string{"-ot", "exps=CPU"}, notes: 1},
{name: "auto, --n-cpu-moe : rien", cfg: auto, extra: []string{"--n-cpu-moe", "20"}, notes: 1},
{name: "auto, -cmoe : rien", cfg: auto, extra: []string{"-cmoe"}, notes: 1},
{name: "auto, -sm row : rien", cfg: auto, extra: []string{"-sm", "row"}, notes: 1},
{name: "auto, -fit off : rien", cfg: auto, extra: []string{"-fit", "off"}, notes: 1},
{name: "auto, -dev : rien", cfg: auto, extra: []string{"-dev", "CUDA0"}, notes: 1},
{name: "auto, LLAMA_ARG_DEVICE : rien", cfg: auto, notes: 1,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_DEVICE"] = "CUDA0" }},
{name: "auto, CTX=0 (fit pourrait réduire le contexte) : rien", cfg: with(auto, "CTX", "0"), notes: 1},
{name: "auto, projecteur vision : rien", cfg: auto, notes: 1,
si: func(s *serveSysInfo) { s.MMProj = "/models/mmproj-F16.gguf" }},
{name: "auto, build inconnu : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.EngineBuild = 0 }},
{name: "auto, moteur trop ancien : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.EngineBuild = 10900 }},
{name: "auto, qwen4exp : jamais d'office", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.GGUF.Arch = "qwen4exp" }},
{name: "auto, essai précédent sans réponse : rien", cfg: auto, notes: 1,
si: func(s *serveSysInfo) {
s.SpecAutoBlocked = "le dernier lancement avec cette configuration n'a jamais répondu"
}},
{name: "auto, moteur sans draft-mtp : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpecOld }},
{name: "auto, aide illisible : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.Help = "" }},
{name: "mtp imposé malgré -ts : drapeaux, avec l'avertissement", cfg: forced,
extra: []string{"-ts", "0.965,0.035"}, want: mtp, notes: 1},
{name: "mtp imposé sur qwen4exp, build inconnu", cfg: forced, want: mtp, notes: 1,
si: func(s *serveSysInfo) { s.GGUF.Arch = "qwen4exp"; s.EngineBuild = 0 }},
{name: "mtp imposé, moteur sans draft-mtp : rien", cfg: forced, notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpecOld }},
{name: "MODEL_DRAFT tête MTP à part : -md chemin absolu + draft-mtp",
cfg: with(auto, "MODEL_DRAFT", "mtp-Qwen3.6-27B-Q8_0.gguf"), wantAuto: true, notes: 1,
want: []string{"-md", "/models/mtp-Qwen3.6-27B-Q8_0.gguf", "--spec-type", "draft-mtp", "--spec-draft-sampling", "greedy"},
si: func(s *serveSysInfo) {
s.GGUF.HasNextNTensor = false
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
}},
{name: "MODEL_DRAFT = le modèle lui-même : pas de -md, la tête intégrée, et on le dit",
cfg: with(forced, "MODEL_DRAFT", "Qwen3.6-27B-MTP.gguf"), notes: 2, want: mtp,
si: func(s *serveSysInfo) {
s.Draft = s.Model
s.DraftGGUF = s.GGUF
s.DraftIsModel = true
}},
{name: "MODEL_DRAFT petit modèle : -md + draft-simple, sinon chargé pour rien",
cfg: with(forced, "MODEL_DRAFT", "Qwen3-0.6B.gguf"), notes: 1,
want: []string{"-md", "/models/Qwen3-0.6B.gguf", "--spec-type", "draft-simple", "--spec-draft-sampling", "greedy"},
si: func(s *serveSysInfo) {
s.Draft = "/models/Qwen3-0.6B.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "qwen3", BlockCount: 28}
}},
{name: "MODEL_DRAFT tête dFlash : pas de draft-simple, réglage à la main",
cfg: with(forced, "MODEL_DRAFT", "dflash-Qwen3.6.gguf"), notes: 1,
si: func(s *serveSysInfo) {
s.Draft = "/models/dflash-Qwen3.6.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "dflash", BlockCount: 5}
}},
{name: "MODEL_DRAFT tête EAGLE-3 : idem",
cfg: with(auto, "MODEL_DRAFT", "eagle3.gguf"), notes: 1,
si: func(s *serveSysInfo) {
s.Draft = "/models/eagle3.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "eagle3", BlockCount: 1}
}},
{name: "MODEL_DRAFT introuvable : moteur lancé sans, pas d'erreur",
cfg: with(auto, "MODEL_DRAFT", "absent.gguf"), notes: 1,
si: func(s *serveSysInfo) { s.DraftErr = "fichier introuvable" }},
{name: "MODEL_DRAFT illisible : rien", cfg: with(auto, "MODEL_DRAFT", "mtp-x.gguf"), notes: 1,
si: func(s *serveSysInfo) { s.Draft = "/models/mtp-x.gguf" }},
{name: "SPEC_N_MAX=2", cfg: with(auto, "SPEC_N_MAX", "2"), wantAuto: true, notes: 1,
want: []string{"--spec-type", "draft-mtp", "--spec-draft-n-max", "2", "--spec-draft-sampling", "greedy"}},
{name: "SPEC_N_MAX illisible : défaut du moteur", cfg: with(auto, "SPEC_N_MAX", "beaucoup"), want: mtp, wantAuto: true, notes: 2},
{name: "--spec-draft-n-max dans EXTRA_ARGS : SPEC_N_MAX se tait, MTP reste",
cfg: with(auto, "SPEC_N_MAX", "2"), extra: []string{"--spec-draft-n-max", "4"}, want: mtp, wantAuto: true, notes: 1},
{name: "probabilistic jamais avec auto",
cfg: with(auto, "SPEC_SAMPLING", "probabilistic"), want: mtp, wantAuto: true, notes: 2},
{name: "probabilistic avec mtp imposé", cfg: with(forced, "SPEC_SAMPLING", "probabilistic"), notes: 1,
want: []string{"--spec-type", "draft-mtp", "--spec-draft-sampling", "probabilistic"}},
{name: "probabilistic refusé avec mirostat", cfg: with(forced, "SPEC_SAMPLING", "probabilistic"),
extra: []string{"--mirostat", "2"}, want: mtp, notes: 2},
{name: "probabilistic refusé avec adaptive-p", cfg: with(forced, "SPEC_SAMPLING", "probabilistic"),
extra: []string{"--samplers", "top_k;adaptive_p"}, want: mtp, notes: 2},
{name: "--spec-draft-sampling dans EXTRA_ARGS : on ne le double pas", cfg: auto,
extra: []string{"--spec-draft-sampling", "greedy"}, want: []string{"--spec-type", "draft-mtp"}, wantAuto: true, notes: 1},
{name: "moteur sans --spec-draft-sampling : pas de drapeau", cfg: auto, wantAuto: true, notes: 1,
want: []string{"--spec-type", "draft-mtp"},
si: func(s *serveSysInfo) {
s.Help = strings.Replace(s.Help, "--spec-draft-sampling {greedy,probabilistic}", "", 1)
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := mtpReady()
if c.si != nil {
c.si(&si)
}
got, notes, isAuto := specArgs(c.cfg, c.extra, si)
if !reflect.DeepEqual(got, c.want) {
t.Errorf("args = %q, attendu %q", got, c.want)
}
if isAuto != c.wantAuto {
t.Errorf("auto = %v, attendu %v", isAuto, c.wantAuto)
}
if len(notes) != c.notes {
t.Errorf("%d notes, attendu %d : %q", len(notes), c.notes, notes)
}
if specMode(c.cfg) == "auto" && slices.Contains(got, "probabilistic") {
t.Errorf("SPEC=auto ne doit jamais choisir le tirage probabiliste : %q", got)
}
for _, a := range got {
if a == "--draft" || a == "--draft-max" || a == "--draft-n" || a == "-ctkd" || a == "-ctvd" {
t.Errorf("drapeau interdit %q", a)
}
}
})
}
}
// La spéculation passe avant EXTRA_ARGS, qui garde le dernier mot.
func TestBuildServeArgsSpec(t *testing.T) {
si := mtpReady()
args, _, _ := buildServeArgs(map[string]string{"SPEC": "auto"}, []string{"--jinja"}, "/bin/llama-server", si)
i := slices.Index(args, "--spec-type")
if i < 0 || args[i+1] != "draft-mtp" {
t.Fatalf("--spec-type draft-mtp absent : %q", args)
}
if j := slices.Index(args, "--jinja"); j < i {
t.Errorf("EXTRA_ARGS doit fermer la marche : %q", args)
}
args, _, _ = buildServeArgs(map[string]string{}, nil, "/bin/llama-server", si)
if slices.Contains(args, "--spec-type") {
t.Errorf("SPEC absent : aucun drapeau attendu, %q", args)
}
}
func TestStatefulSampler(t *testing.T) {
cases := map[string][]string{
"": {"--mirostat", "0"},
"--mirostat 1": {"--mirostat=1"},
"--samplers adaptive-p": {"--samplers", "penalties;adaptive-p"},
"--sampling-seq avec adaptive-p": {"--sampling-seq", "kpa"},
}
for want, extra := range cases {
if got := statefulSampler(extra); got != want {
t.Errorf("statefulSampler(%q) = %q, attendu %q", extra, got, want)
}
}
if got := statefulSampler([]string{"--samplers", "top_k;top_p;temperature"}); got != "" {
t.Errorf("chaîne sans état : %q", got)
}
}
func TestSpecAutoVerdict(t *testing.T) {
cur := specAutoMark{FP: "abc", Bin: "/e/llama-server", Build: 11351}
other := specAutoMark{FP: "abc", Bin: "/e/llama-server", Build: 11400}
if why, rec := specAutoVerdict(cur, nil, nil); why != "" || rec {
t.Errorf("aucun essai : permis, got %q %v", why, rec)
}
if why, rec := specAutoVerdict(cur, &cur, map[string]string{}); why == "" || !rec {
t.Errorf("jeton resté pour la même combinaison : coupé et inscrit, got %q %v", why, rec)
}
if why, rec := specAutoVerdict(cur, &other, map[string]string{}); why != "" || rec {
t.Errorf("jeton d'un autre build : permis (mise à jour = nouvel essai), got %q %v", why, rec)
}
if why, rec := specAutoVerdict(cur, nil, map[string]string{cur.key(): "OOM"}); why != "OOM" || rec {
t.Errorf("échec inscrit : coupé, got %q %v", why, rec)
}
}
// Le cycle complet sur une vraie base : jeton posé, jamais effacé, puis coupé
// pour cette combinaison seulement.
func TestSpecAutoCheckStore(t *testing.T) {
testHome(t)
// Ce que fait cmdServe : lire, puis — port libre — ranger et poser le jeton.
launch := func(cur specAutoMark, wantAttempt bool) string {
why, record := specAutoPeek(cur)
specAutoSettle(cur, why, record, wantAttempt && why == "")
return why
}
cur := specAutoMark{FP: "abc", Bin: "/e/llama-server", Build: 11351}
if why := launch(cur, true); why != "" {
t.Fatalf("base vide : permis, got %q", why)
}
// Second « loki serve » refusé (port pris) : il a lu, sans rien ranger. Le
// jeton du premier, qui charge encore, doit rester intact.
if why, _ := specAutoPeek(cur); why == "" {
t.Fatal("jeton présent : le lecteur doit le voir")
}
var a specAutoMark
if !getJSON(bkState, specAttemptKey, &a) || a != cur {
t.Fatal("specAutoPeek ne doit rien consommer")
}
if why := launch(cur, true); why == "" {
t.Fatal("jeton resté : l'auto doit être coupé")
}
if getJSON(bkState, specAttemptKey, &a) {
t.Error("auto coupé : aucun nouveau jeton")
}
if why := launch(cur, true); why == "" {
t.Error("l'échec doit rester inscrit au lancement suivant")
}
updated := cur
updated.Build = 11400
if why := launch(updated, true); why != "" {
t.Errorf("moteur mis à jour : nouvel essai permis, got %q", why)
}
// Le process web efface SON jeton ; pas celui d'un lancement plus récent.
specAttemptClear(cur, "")
if !getJSON(bkState, specAttemptKey, &a) || a != updated {
t.Fatal("le jeton d'un autre lancement doit rester")
}
specAttemptClear(updated, "")
if getJSON(bkState, specAttemptKey, &a) {
t.Fatal("le moteur a répondu : jeton effacé")
}
if why := launch(updated, true); why != "" {
t.Errorf("lancement réussi : permis, got %q", why)
}
// Répondu mais couches en RAM : échec inscrit pour cette combinaison.
specAttemptClear(updated, "couches en RAM")
if why, _ := specAutoPeek(updated); why != "couches en RAM" {
t.Errorf("échec après chargement : coupé, got %q", why)
}
// Un jeton d'une autre configuration est jeté sans rien conclure.
other := specAutoMark{FP: "zzz", Bin: "/e/llama-server", Build: 11351}
specAutoSettle(other, "", false, true)
if why := launch(cur, false); why == "" {
t.Error("échec de cur toujours inscrit")
}
if getJSON(bkState, specAttemptKey, &a) {
t.Error("le jeton étranger doit être consommé")
}
}
func TestLastOffload(t *testing.T) {
log := strings.Join([]string{
"srv load_model: loading model '/models/old.gguf'",
"load_tensors: offloaded 40/65 layers to GPU",
"srv load_model: loading model '/models/new.gguf'",
"load_tensors: offloaded 65/65 layers to GPU",
"main: server is listening",
}, "\n")
if n, m := lastOffload(log); n != 65 || m != 65 {
t.Errorf("dernier chargement seulement : got %d/%d", n, m)
}
if n, m := lastOffload("load_tensors: offloaded 10/65 layers to GPU"); n != 0 || m != 0 {
t.Errorf("sans ligne de chargement, on ne conclut rien : got %d/%d", n, m)
}
if n, m := lastOffload("srv load_model: loading model 'x'\nload_tensors: offloaded 50/65 layers to GPU"); n != 50 || m != 65 {
t.Errorf("chargement partiel : got %d/%d", n, m)
}
// Le journal réel de llama-server : « load_model: initializing » APRÈS le
// chargement, et un brouillon chargé à part qui a ses propres lignes.
srvLog := strings.Join([]string{
"srv load_model: loading model '/models/Qwen3.6-27B.gguf'",
"load_tensors: loading model tensors, this can take a while... (load_mode = mmap)",
"load_tensors: offloaded 52/65 layers to GPU",
"load_tensors: loading model tensors, this can take a while... (load_mode = mmap)",
"load_tensors: offloaded 2/2 layers to GPU",
"srv load_model: initializing, n_slots = 1, n_ctx_slot = 65536, kv_unified = 'false'",
"main: server is listening on http://127.0.0.1:8080",
}, "\n")
if n, m := lastOffload(srvLog); n != 52 || m != 65 {
t.Errorf("modèle partiel, brouillon complet : le pire compte, got %d/%d", n, m)
}
full := strings.Replace(srvLog, "52/65", "65/65", 1)
if n, m := lastOffload(full); n != m || m == 0 {
t.Errorf("tout sur GPU : got %d/%d", n, m)
}
}
// helpNgram : un moteur qui connaît les drapeaux actuels de ngram-mod.
const helpNgram = helpSpec + `--spec-ngram-mod-n-min N minimum number of ngram tokens (default: 48)
--spec-ngram-mod-n-max N maximum number of ngram tokens (default: 64)
--spec-ngram-mod-n-match N ngram-mod lookup length (default: 24)
`
func TestSpecArgsNgram(t *testing.T) {
ngram := []string{"--spec-type", "ngram-mod",
"--spec-ngram-mod-n-match", "24", "--spec-ngram-mod-n-min", "48", "--spec-ngram-mod-n-max", "64"}
both := []string{"--spec-type", "draft-mtp,ngram-mod",
"--spec-ngram-mod-n-match", "24", "--spec-ngram-mod-n-min", "48", "--spec-ngram-mod-n-max", "64",
"--spec-draft-sampling", "greedy"}
cfgOf := func(kv ...string) map[string]string {
m := map[string]string{}
for i := 0; i+1 < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
}
dense := func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "llama", BlockCount: 32} }
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
notes int
}{
{name: "ngram, modèle dense : forme explicite, jamais --spec-default",
cfg: cfgOf("SPEC", "ngram"), si: dense, want: ngram, notes: 1},
{name: "ngram+mtp s'écrit aussi dans l'autre sens",
cfg: cfgOf("SPEC", "ngram+mtp"), want: both, notes: 2},
{name: "ngram sur un modèle hybride à tête MTP : tête inutilisée et points de reprise dits",
cfg: cfgOf("SPEC", "ngram"), want: ngram, notes: 3},
{name: "ngram, moteur qui n'a que le type dans sa liste : rien",
cfg: cfgOf("SPEC", "ngram"), notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpec }},
{name: "mtp+ngram, moteur sans drapeaux ngram-mod : rien",
cfg: cfgOf("SPEC", "mtp+ngram"), notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpec }},
{name: "mtp+ngram avec la tête dans le fichier : une seule liste de types",
cfg: cfgOf("SPEC", "mtp+ngram"), want: both, notes: 2},
{name: "mtp+ngram sans tête MTP : n-grammes seuls, jamais « failed to create MTP context »",
cfg: cfgOf("SPEC", "mtp+ngram"), si: dense, want: ngram, notes: 1},
{name: "mtp+ngram, moteur sans draft-mtp : n-grammes seuls",
cfg: cfgOf("SPEC", "mtp+ngram"), want: ngram, notes: 2,
si: func(s *serveSysInfo) {
s.Help = strings.ReplaceAll(s.Help, "draft-mtp", "draft-xxx")
}},
{name: "mtp+ngram avec un petit modèle brouillon : n-grammes seuls, pas de -md",
cfg: cfgOf("SPEC", "mtp+ngram", "MODEL_DRAFT", "Qwen3-0.6B.gguf"), want: ngram, notes: 2,
si: func(s *serveSysInfo) {
s.Draft = "/models/Qwen3-0.6B.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "qwen3", BlockCount: 28}
}},
{name: "mtp+ngram avec la tête MTP publiée à part",
cfg: cfgOf("SPEC", "mtp+ngram", "MODEL_DRAFT", "mtp-q.gguf"), notes: 2,
want: append([]string{"-md", "/models/mtp-q.gguf"}, both...),
si: func(s *serveSysInfo) {
s.GGUF.HasNextNTensor = false
s.Draft = "/models/mtp-q.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
}},
{name: "ngram + MODEL_DRAFT : brouillon ignoré, et dit",
cfg: cfgOf("SPEC", "ngram", "MODEL_DRAFT", "mtp-q.gguf"), si: dense, want: ngram, notes: 2},
{name: "ngram + SPEC_N_MAX : ignoré, et dit",
cfg: cfgOf("SPEC", "ngram", "SPEC_N_MAX", "4"), si: dense, want: ngram, notes: 2},
{name: "--spec-type dans EXTRA_ARGS : Loki se tait",
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--spec-type", "ngram-simple"}, notes: 1},
{name: "--spec_type=… dans EXTRA_ARGS : Loki se tait",
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--spec_type=ngram-mod"}, notes: 1},
{name: "--spec-default dans EXTRA_ARGS : Loki se tait",
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"--spec-default"}, notes: 1},
{name: "--spec-ngram-mod-n-max dans EXTRA_ARGS : Loki se tait",
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--spec-ngram-mod-n-max=16"}, notes: 1},
{name: "-md dans EXTRA_ARGS : Loki se tait",
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"-md", "d.gguf"}, notes: 1},
{name: "LLAMA_ARG_SPEC_TYPE posée : Loki se tait", cfg: cfgOf("SPEC", "ngram"), notes: 1,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SPEC_TYPE"] = "ngram-mod" }},
{name: "ngram + --spec-draft-n-max à la main : rien",
cfg: cfgOf("SPEC", "ngram"), si: dense, extra: []string{"--spec-draft-n-max", "8"}, notes: 1},
{name: "mtp+ngram + --spec-draft-n-max à la main : la règle de SPEC=mtp",
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"--spec-draft-n-max", "8"}, want: both, notes: 2},
{name: "experts sur CPU, seuil par défaut : refusé, OP_OFFLOAD_MIN_BATCH suggéré",
cfg: cfgOf("SPEC", "ngram"), si: dense, extra: []string{"--n-cpu-moe", "40"}, notes: 1},
{name: "experts sur CPU, mtp+ngram : refusé aussi",
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"-ot", "exps=CPU"}, notes: 1},
{name: "experts sur CPU, OP_OFFLOAD_MIN_BATCH=64 : encore sous le lot de 65, refusé",
cfg: cfgOf("SPEC", "ngram", "OP_OFFLOAD_MIN_BATCH", "64"), si: dense, extra: []string{"-cmoe"}, notes: 1},
{name: "experts sur CPU, OP_OFFLOAD_MIN_BATCH=128 : accepté",
cfg: cfgOf("SPEC", "ngram", "OP_OFFLOAD_MIN_BATCH", "128"), si: dense, extra: []string{"-cmoe"}, want: ngram, notes: 1},
{name: "experts sur CPU, GGML_OP_OFFLOAD_MIN_BATCH=256 dans l'environnement : accepté",
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--n-cpu-moe", "40"}, want: ngram, notes: 1,
si: func(s *serveSysInfo) {
dense(s)
s.UserEnv = map[string]string{"GGML_OP_OFFLOAD_MIN_BATCH": "256"}
}},
{name: "experts sur CPU, modèle plus gros que la RAM : accepté avec l'avertissement",
cfg: cfgOf("SPEC", "ngram", "OP_OFFLOAD_MIN_BATCH", "128"), extra: []string{"--n-cpu-moe", "40"},
want: ngram, notes: 2,
si: func(s *serveSysInfo) {
dense(s)
s.ModelBytes, s.RAMMiB = 82<<30, 64<<10
}},
{name: "NGL imposé : logits en VRAM dits",
cfg: cfgOf("SPEC", "ngram", "NGL", "40"), si: dense, want: ngram, notes: 2},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := mtpReady()
si.Help = helpNgram
if c.si != nil {
c.si(&si)
}
got, notes, isAuto := specArgs(c.cfg, c.extra, si)
if !reflect.DeepEqual(got, c.want) {
t.Errorf("args = %q, attendu %q", got, c.want)
}
if isAuto {
t.Error("ngram n'est jamais automatique")
}
if len(notes) != c.notes {
t.Errorf("%d notes, attendu %d : %q", len(notes), c.notes, notes)
}
for _, a := range got {
if a == "--spec-default" || strings.HasPrefix(a, "--spec-synth") {
t.Errorf("drapeau interdit %q", a)
}
}
})
}
}
// Sans SPEC, la même ligne qu'avant, quel que soit le moteur.
func TestBuildServeArgsNgramDefault(t *testing.T) {
si := mtpReady()
si.Help = helpNgram
base, _, _ := buildServeArgs(map[string]string{}, nil, "/bin/llama-server", si)
for _, a := range base {
if strings.HasPrefix(a, "--spec") {
t.Fatalf("SPEC absent : aucun drapeau de spéculation, %q", base)
}
}
got, _, _ := buildServeArgs(map[string]string{"SPEC": "ngram"}, []string{"--jinja"}, "/bin/llama-server", si)
i := slices.Index(got, "--spec-type")
if i < 0 || got[i+1] != "ngram-mod" || slices.Index(got, "--jinja") < i {
t.Fatalf("SPEC=ngram : %q", got)
}
}
func TestSynthAcceptanceWarned(t *testing.T) {
for _, extra := range [][]string{{"--spec-synth-len", "3"}, {"--spec-synth-rates=0.5,0.5"}} {
if n := lossyCacheNotes(extra, serveSysInfo{ArgEnv: map[string]string{}}); len(n) != 1 ||
!strings.Contains(n[0], "AU HASARD") {
t.Errorf("%q : %q", extra, n)
}
}
si := serveSysInfo{ArgEnv: map[string]string{"LLAMA_ARG_SPEC_SYNTH_LEN": "3"}}
if n := lossyCacheNotes(nil, si); len(n) != 1 {
t.Errorf("variable : %q", n)
}
}
// probeSpec reconnaît un MODEL_DRAFT qui désigne le modèle principal.
func TestProbeSpecDraftIsModel(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
dir := t.TempDir()
model := dir + "/Swift-27B.gguf"
other := dir + "/mtp-Swift.gguf"
for _, p := range []string{model, other} {
if err := os.WriteFile(p, []byte("GGUF"), 0o644); err != nil {
t.Fatal(err)
}
}
si := serveSysInfo{Model: model}
probeSpec(map[string]string{"MODEL_DRAFT": model}, &si)
if !si.DraftIsModel {
t.Errorf("MODEL_DRAFT = le modèle, non reconnu (Draft=%q, err=%q)", si.Draft, si.DraftErr)
}
si = serveSysInfo{Model: model}
probeSpec(map[string]string{"MODEL_DRAFT": other}, &si)
if si.DraftIsModel || si.Draft != other {
t.Errorf("tête à part prise pour le modèle : %+v", si)
}
}
+381
View File
@@ -0,0 +1,381 @@
package loki
import (
"context"
"fmt"
"math"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
// Parallélisme de tenseurs (SPLIT_MODE=tensor, off par défaut) : expérimental.
//
// La découpe par couches (défaut de llama.cpp) donne à chaque carte des
// couches entières, traversées l'une après l'autre. En mode tensor, chaque
// couche est coupée entre les cartes, qui calculent en même temps puis
// additionnent leurs morceaux (all-reduce). Sur deux RTX 4090, l'amont mesure
// +21-30 % au décodage, mais -37 % au prefill — et la boucle d'agent de Loki
// relit beaucoup. Sur deux GeForce en PCIe sans P2P, la somme passe par
// l'hôte : un gain net par tour est loin d'être acquis. D'où l'opt-in, et une
// longue liste de refus propres plutôt qu'un moteur qui meurt en boucle.
//
// Fidélité : l'all-reduce change l'ordre des additions (bruit de virgule
// flottante), mais le chemin NCCL va plus loin — au-delà de 32 768 éléments il
// COMPRESSE les sommes partielles en BF16 (ggml-cuda.cu), soit à chaque couche
// de chaque prefill : une perte de précision du calcul, de la classe d'une
// quantification. Loki pose donc GGML_CUDA_ALLREDUCE=internal (réduction dans
// le type natif), sauf si l'utilisateur a choisi lui-même. Le cache KV doit
// rester f16, bf16 ou f32 : le mode tensor ne gère pas les caches quantifiés,
// et Loki ne réécrit jamais un type de cache d'office (ça change le budget
// VRAM). Pas de --fit en mode tensor (common/fit.cpp) : Loki estime la VRAM
// lui-même et refuse si le compte ne tient pas, sans jamais réduire CTX.
// splitDev : une carte telle que la voit CE moteur (--list-devices), dans son
// ordre — celui que suivent -ts et --device.
type splitDev struct {
ID string // CUDA0, Vulkan1…
TotalMiB int64
}
// splitTensorDenyArch : les architectures que llama.cpp refuse en mode tensor
// (llm_arch_supports_sm_tensor) — il mourrait au chargement, et systemd
// relancerait en boucle. qwen4exp n'y est pas, mais Loki l'écarte : son preset
// place les experts sur CPU avec un cache q8_0, l'inverse de ce qu'exige le
// mode tensor.
var splitTensorDenyArch = map[string]bool{
"grok": true, "mpt": true, "plamo2": true, "minicpm3": true, "gemma3n": true, "mamba": true, "mamba2": true,
"jamba": true, "falcon-h1": true, "olmo2": true, "olmoe": true, "deepseek2": true, "deepseek32": true,
"hy_v4": true, "dots3note": true, "glm-dsa": true, "bitnet": true, "t5": true, "nemotron_h": true,
"nemotron_h_moe": true, "granitehybrid": true, "minimax-01": true, "minimax-m2": true, "minimax-m3": true,
"mistral4": true, "kimi-linear": true, "bailingmoe3": true, "kimi-k3": true, "glm5-next": true,
"qwen3tts": true, "qwen4exp": true,
}
// splitArgEnv : les variables qui décident de ce que SPLIT_MODE règlerait.
var splitArgEnv = []string{"LLAMA_ARG_FLASH_ATTN", "LLAMA_ARG_BACKEND_SAMPLING", "LLAMA_ARG_TENSOR_SPLIT"}
// splitModeKey lit SPLIT_MODE : "" (clé absente, off ou layer — le défaut du
// moteur, rien à poser), "tensor", ou "?" si illisible.
func splitModeKey(cfg map[string]string) string {
switch v := strings.ToLower(strings.TrimSpace(cfg["SPLIT_MODE"])); v {
case "", "off", "layer", "non", "no", "0":
return ""
case "tensor":
return v
}
return "?"
}
// helpSupportsSplitTensor : la liste LITTÉRALE des valeurs de -sm. Le mot
// « tensor » seul est partout dans l'aide (--tensor-split, --override-tensor) :
// s'y fier ferait passer -sm tensor à un moteur qui le refuse au démarrage.
func helpSupportsSplitTensor(help string) bool {
return strings.Contains(help, "{none,layer,row,tensor}")
}
// probeSplit : seulement si SPLIT_MODE=tensor et que le moteur le propose —
// sans la clé, rien n'est lu ni lancé. --list-devices tourne dans
// l'environnement du vrai lancement (bibliothèques, CUDA_VISIBLE_DEVICES et
// PCI_BUS_ID déjà posés par cmdServe) : même ordre de cartes que le moteur.
func probeSplit(cfg map[string]string, bin string, si *serveSysInfo) {
if splitModeKey(cfg) != "tensor" || !helpSupportsSplitTensor(si.Help) {
return
}
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range splitArgEnv {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
if si.UserEnv == nil {
si.UserEnv = map[string]string{}
}
if v := os.Getenv("GGML_CUDA_ALLREDUCE"); v != "" {
si.UserEnv["GGML_CUDA_ALLREDUCE"] = v
}
si.SplitDevs = listEngineDevices(bin)
}
// listEngineDevices : les cartes que CE moteur voit (--list-devices), dans
// l'environnement du processus — celui du vrai lancement quand cmdServe
// appelle. nil si le moteur échoue : sortie tronquée (carte pleine, moteur
// planté), on ne s'y fie pas.
func listEngineDevices(bin string) []splitDev {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
out, err := hideCmd(exec.CommandContext(ctx, bin, "--list-devices")).CombinedOutput()
if err != nil {
return nil
}
return splitDevsFrom(parseListDevices(string(out)))
}
// splitDevsFrom : les cartes de parseListDevices (ou de devices.json, relu en
// JSON : les nombres y sont des float64).
func splitDevsFrom(list []map[string]any) []splitDev {
var devs []splitDev
for _, d := range list {
id, _ := d["id"].(string)
var total int64
switch v := d["total_mib"].(type) {
case int:
total = int64(v)
case float64:
total = int64(v)
}
devs = append(devs, splitDev{ID: id, TotalMiB: total})
}
return devs
}
// splitPlan décide de SPLIT_MODE=tensor pour ce lancement. Fonction pure.
//
// on : accepté ; la ligne gagne -sm tensor et ses compagnons
// why : pourquoi refusé (vide = clé absente)
// devs : les cartes du découpage, dans l'ordre du moteur
// needMiB : la VRAM estimée (poids + cache de CTX jetons)
func splitPlan(cfg map[string]string, extra []string, si serveSysInfo) (on bool, why string, devs []splitDev, needMiB int64) {
switch splitModeKey(cfg) {
case "":
return false, "", nil, 0
case "?":
return false, "SPLIT_MODE=" + strings.TrimSpace(cfg["SPLIT_MODE"]) + " illisible (tensor ou off)", nil, 0
}
refuse := func(why string) (bool, string, []splitDev, int64) { return false, why, nil, 0 }
if isExternalConfig(cfg) {
return refuse("preset externe")
}
if !helpSupportsSplitTensor(si.Help) {
return refuse("ce moteur ne propose pas -sm tensor")
}
if sm := flagValue(extra, "-sm", "--split-mode"); sm != "" {
return refuse("-sm " + sm + " déjà dans EXTRA_ARGS")
}
if si.ArgEnv["LLAMA_ARG_SPLIT_MODE"] != "" {
return refuse("LLAMA_ARG_SPLIT_MODE déjà posée")
}
fa := flagValue(extra, "-fa", "--flash-attn")
if fa == "" {
fa = si.ArgEnv["LLAMA_ARG_FLASH_ATTN"]
}
switch strings.ToLower(strings.TrimSpace(fa)) {
case "off", "disabled", "false", "0":
return refuse("Flash Attention coupée (-fa " + fa + ") : le mode tensor l'exige")
}
if hasAnyFlag(extra, "-bs", "--backend-sampling") || envTruthy(si.ArgEnv["LLAMA_ARG_BACKEND_SAMPLING"]) {
return refuse("échantillonnage sur GPU (--backend-sampling) non pris en charge en mode tensor")
}
k, v, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
if !splitKVOK(k) || !splitKVOK(v) {
return refuse(fmt.Sprintf("cache KV %s/%s : le mode tensor n'accepte que f16, bf16 ou f32 "+
"(jamais réécrit d'office : la VRAM changerait)", kvName(k), kvName(v)))
}
if r := tensorOverride(extra, si.ArgEnv); r != "" {
return refuse(r + " : le mode tensor met tout sur les cartes")
}
if !kvOffloaded(extra, si.ArgEnv) {
return refuse("cache KV sur CPU")
}
switch {
case si.GGUF == nil || si.ModelBytes <= 0:
return refuse("métadonnées ou taille du modèle illisibles")
case splitTensorDenyArch[strings.ToLower(si.GGUF.Arch)]:
return refuse("architecture " + si.GGUF.Arch + " exclue du mode tensor")
}
if _, why := splitNGL(cfg, extra, si.GGUF.BlockCount); why != "" {
return refuse(why)
}
ctxS, ok := ctxFixed(cfg, extra)
if !ok {
return refuse("contexte « " + ctxS + " » non chiffré : pas de --fit en mode tensor, donne un CTX explicite")
}
if sideSlotOn(cfg) {
return refuse("SIDE_SLOT : cache doublé, sans --fit pour rattraper")
}
if specMode(cfg) != "off" || specUserSet(extra, si.ArgEnv) {
return refuse("décodage spéculatif : brouillon et vérification par lots non validés en mode tensor")
}
devs, why = splitDevices(extra, si)
if why != "" {
return refuse(why)
}
// VRAM : poids + cache de CTX jetons (et état récurrent) contre 90 % des
// cartes, la marge de SIDE_SLOT. Les tampons de calcul n'y sont pas : c'est
// un minimum, pas une promesse — le journal dira le reste.
ctx, _ := strconv.Atoi(ctxS)
perTok, rec := ggufStateBytes(*si.GGUF, k, v)
if perTok <= 0 {
return refuse("taille du cache KV inconnue pour ce modèle")
}
var total int64
for _, d := range devs {
total += d.TotalMiB
}
mm := int64(0)
if si.MMProj != "" {
if st, err := os.Stat(si.MMProj); err == nil {
mm = st.Size()
}
}
need := float64(si.ModelBytes+mm) + perTok*float64(ctx) + float64(rec)
needMiB = int64(math.Ceil(need / (1 << 20)))
if need > 0.9*float64(total)*(1<<20) {
return refuse(fmt.Sprintf("VRAM insuffisante sans --fit : ~%d Mio (poids + cache de %d jetons) pour %d Mio "+
"sur %d cartes — CTX n'est jamais réduit d'office", needMiB, ctx, total, len(devs)))
}
return true, "", devs, needMiB
}
func splitKVOK(t string) bool {
switch strings.ToLower(strings.TrimSpace(t)) {
case "", "f16", "bf16", "f32":
return true
}
return false
}
func kvName(t string) string {
if t == "" {
return "f16"
}
return t
}
// splitNGL : les couches GPU en mode tensor, où tout doit être sur les cartes.
// Un -ngl d'EXTRA_ARGS reste celui de l'utilisateur (rien à ajouter) s'il dit
// « tout » ; sinon NGL est traduit en « -ngl all » (999, auto et la clé absente
// visent déjà le tout-GPU, mais auto ne fait rien sans --fit). Une valeur qui
// laisse des couches au CPU est refusée.
func splitNGL(cfg map[string]string, extra []string, blocks int) (args []string, why string) {
all := func(v string) bool {
v = strings.ToLower(strings.TrimSpace(v))
if v == "all" || v == "999" {
return true
}
n, err := strconv.Atoi(v)
return err == nil && blocks > 0 && n >= blocks+1
}
if v := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers"); v != "" {
if all(v) {
return nil, ""
}
return nil, "-ngl " + v + " dans EXTRA_ARGS : le mode tensor veut toutes les couches sur les cartes (all)"
}
switch v := strings.TrimSpace(cfg["NGL"]); {
case v == "" || strings.EqualFold(v, "auto") || all(v):
return []string{"-ngl", "all"}, ""
default:
return nil, "NGL=" + v + " : le mode tensor veut toutes les couches sur les cartes (all)"
}
}
// splitDevices : les cartes du découpage. --device (EXTRA_ARGS ou variable)
// choisit lesquelles, dans son ordre ; sinon toutes celles que liste le
// moteur. Toutes CUDA (l'all-reduce n'existe qu'entre cartes CUDA), deux au
// moins.
func splitDevices(extra []string, si serveSysInfo) ([]splitDev, string) {
if len(si.SplitDevs) == 0 {
return nil, "cartes inconnues (--list-devices n'a rien répondu)"
}
devs := si.SplitDevs
sel := flagValue(extra, "-dev", "--device")
if sel == "" {
sel = si.ArgEnv["LLAMA_ARG_DEVICE"]
}
if sel = strings.TrimSpace(sel); sel != "" {
devs = nil
for _, name := range strings.Split(sel, ",") {
name = strings.TrimSpace(name)
found := false
for _, d := range si.SplitDevs {
if strings.EqualFold(d.ID, name) {
devs, found = append(devs, d), true
break
}
}
if !found {
return nil, "--device " + name + " inconnu de ce moteur"
}
}
}
for _, d := range devs {
if !strings.HasPrefix(strings.ToUpper(d.ID), "CUDA") {
return nil, d.ID + " n'est pas une carte CUDA"
}
}
if len(devs) < 2 {
return nil, "une seule carte"
}
return devs, ""
}
// splitTS : la répartition par défaut, au prorata de la VRAM TOTALE de chaque
// carte (la libre varie avec ce qui tourne encore au lancement), en pour cent.
func splitTS(devs []splitDev) string {
var total int64
for _, d := range devs {
total += d.TotalMiB
}
if total <= 0 {
return ""
}
parts := make([]string, len(devs))
for i, d := range devs {
parts[i] = strconv.FormatInt(int64(math.Round(100*float64(d.TotalMiB)/float64(total))), 10)
}
return strings.Join(parts, ",")
}
// splitTensorArgs : ce que SPLIT_MODE=tensor ajoute à la ligne, à la place du -ngl
// habituel (nglOut), l'environnement et les notes. on=false : rien, et la
// ligne reste celle d'avant à l'octet près.
func splitTensorArgs(cfg map[string]string, extra []string, si serveSysInfo) (on bool, args, ngl []string, env map[string]string, notes []string) {
ok, why, devs, needMiB := splitPlan(cfg, extra, si)
if !ok {
if why != "" {
notes = append(notes, "SPLIT_MODE=tensor refusé ("+why+") : découpe par couches, comme sans la clé")
}
return false, nil, nil, nil, notes
}
ngl, _ = splitNGL(cfg, extra, si.GGUF.BlockCount)
args = []string{"-sm", "tensor"}
ts := ""
if !hasAnyFlag(extra, "-ts", "--tensor-split") && si.ArgEnv["LLAMA_ARG_TENSOR_SPLIT"] == "" {
if ts = splitTS(devs); ts != "" {
args = append(args, "-ts", ts)
}
}
// llama.cpp active de lui-même Flash Attention en mode tensor ; l'écrire
// le rend lisible dans le journal. Jamais en double d'un -fa de l'utilisateur.
if !hasAnyFlag(extra, "-fa", "--flash-attn") && si.ArgEnv["LLAMA_ARG_FLASH_ATTN"] == "" {
args = append(args, "-fa", "on")
}
env = map[string]string{}
note := fmt.Sprintf("SPLIT_MODE=tensor (expérimental) : -sm tensor sur %d cartes", len(devs))
if ts != "" {
note += ", -ts " + ts + " (VRAM totale)"
}
note += fmt.Sprintf(", ~%d Mio estimés sans --fit ; décodage plus rapide, prefill plus lent — compare le temps par tour", needMiB)
switch ar := strings.ToLower(strings.TrimSpace(si.UserEnv["GGML_CUDA_ALLREDUCE"])); ar {
case "":
env["GGML_CUDA_ALLREDUCE"] = "internal"
note += " ; GGML_CUDA_ALLREDUCE=internal : sommes dans le type natif (NCCL les compresserait en BF16, avec perte). " +
"« NCCL not compiled in » ou « NCCL is unavailable » au journal : sans effet ici. GeForce sans P2P : les " +
"sommes passent par l'hôte ; GGML_CUDA_P2P=1 à poser soi-même si le pilote le permet"
case "nccl":
note += " ; avertissement : GGML_CUDA_ALLREDUCE=nccl (environnement) — NCCL compresse en BF16 les grosses " +
"réductions du prefill : calcul avec perte. « NCCL not compiled in » au journal = repli sur la réduction interne"
default:
note += " ; GGML_CUDA_ALLREDUCE=" + ar + " (environnement) gardé"
}
if si.GGUF.Hybrid {
note += " ; modèle hybride : état récurrent et points de reprise répartis entre cartes, non validés en amont"
}
notes = append(notes, note)
return true, args, ngl, env, notes
}
+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")
}
}
+351
View File
@@ -0,0 +1,351 @@
package loki
import (
"fmt"
"io/fs"
"sort"
"strconv"
"strings"
)
// Threads CPU de llama-server.
//
// Loki passait toujours « -t <THREADS|0> -tb <THREADS_BATCH|0> ». Or pour
// llama.cpp, 0 (ou moins) veut dire std::thread::hardware_concurrency() : TOUS
// les threads logiques, frères SMT compris. Avec l'attente active par défaut
// (--poll 50), deux threads qui se disputent le même cœur physique se gênent
// plus qu'ils ne s'aident — c'est le décodage des experts MoE sur CPU
// (--n-cpu-moe) qui paie. Le « 0 = auto » de l'interface mentait donc : ce
// n'était pas l'auto de llama.cpp.
//
// Le vrai auto, c'est l'ABSENCE du drapeau : n_threads reste à -1 et le moteur
// prend common_cpu_get_num_math() — les cœurs physiques (sans les cœurs E sur
// Intel hybride, sous Linux x86). Un -tb absent recopie -t, et le brouillon
// spéculatif hérite des deux. Le nombre de threads ne change que la répartition
// du travail, pas le calcul du modèle.
//
// Effet de bord assumé : sans -t de Loki, une variable LLAMA_ARG_THREADS posée
// dans l'environnement du moteur s'applique enfin (le -t 0 l'écrasait).
//
// Gain non mesuré sur la machine de référence : avant d'en revendiquer un,
// comparer tg du preset MoE à physiques, physiques-1 et logiques (MTP actif et
// coupé, la vérification spéculative passant par n_threads_batch). Si les
// logiques gagnent, poser THREADS dans CE preset plutôt que changer le défaut.
// Pour voir le nombre retenu : -lv 4 dans EXTRA_ARGS (system_info est en TRACE).
// cpuBudget est ce que la sonde de conteneur (cpusetThreads) a conclu. N = 0 :
// rien à dire, llama.cpp choisit seul.
type cpuBudget struct {
N int // threads à imposer via -t
Engine int // ce que llama.cpp aurait pris seul, pour la note
Why string // raison, en clair, pour stderr
}
// threadCount lit THREADS / THREADS_BATCH. Vide ou 0 : auto, sans un mot. Une
// valeur illisible (« auto ») ou négative faisait jusqu'ici tomber le moteur en
// boucle sur son analyse d'arguments ; elle est désormais ignorée — mais on le
// DIT, plutôt que de la jeter en douce.
func threadCount(key, v string) (int, string) {
v = strings.TrimSpace(v)
if v == "" {
return 0, ""
}
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return 0, fmt.Sprintf("%s=%s ignoré (pas un nombre de threads ≥ 0) → automatique", key, v)
}
return n, ""
}
// threadArgs compose -t / -tb. Fonction pure, comme nglArgs : la sonde du
// conteneur arrive déjà tranchée dans auto.
//
// THREADS=N>0 → -t N
// THREADS vide/0, sonde N>0 → -t N (conteneur à l'étroit, voir cpusetThreads)
// THREADS vide/0 → rien (cœurs physiques, choisis par llama.cpp)
// THREADS_BATCH=N>0 → -tb N
// THREADS_BATCH vide/0 → rien (le moteur recopie -t)
// … sauf -Cb/--poll-batch… → -tb <valeur de -t, sinon 0> (voir plus bas)
//
// Un -t / -tb déjà écrit dans EXTRA_ARGS gagne : on ne double pas le drapeau.
// Un masque d'affinité posé à la main (-C, -Cr, --cpu-strict) désarme la sonde :
// l'utilisateur a pris la main sur le placement, on ne devine rien par-dessus.
//
// Piège du -tb absent : llama.cpp ne recopie pas seulement le NOMBRE de -t, il
// remplace TOUT le réglage CPU du batch par celui de -t (postprocess_cpu_params,
// « cpuparams = *role_model »). Un -Cb, --poll-batch ou --prio-batch écrit dans
// EXTRA_ARGS serait alors effacé sans un mot — le -tb 0 d'avant le protégeait.
// Dans ce cas seulement, on pose donc -tb : la valeur de -t si on la connaît
// (« comme THREADS »), sinon 0, l'ancien comportement.
func threadArgs(threads, threadsBatch string, extra []string, auto cpuBudget) (args, notes []string) {
t, note := threadCount("THREADS", threads)
if note != "" {
notes = append(notes, note)
}
tVal := "" // valeur de -t effective, si on la connaît
switch {
case hasAnyFlag(extra, "-t", "--threads"):
tVal = flagValue(extra, "-t", "--threads")
case t > 0:
tVal = strconv.Itoa(t)
args = append(args, "-t", tVal)
case auto.N > 0 && !hasAnyFlag(extra, "-C", "--cpu-mask", "-Cr", "--cpu-range", "--cpu-strict"):
tVal = strconv.Itoa(auto.N)
args = append(args, "-t", tVal)
notes = append(notes, fmt.Sprintf("threads auto → -t %d : %s (llama.cpp seul en lancerait %d)",
auto.N, auto.Why, auto.Engine))
}
tb, note := threadCount("THREADS_BATCH", threadsBatch)
if note != "" {
notes = append(notes, note)
}
switch {
case hasAnyFlag(extra, "-tb", "--threads-batch"):
case tb > 0:
args = append(args, "-tb", strconv.Itoa(tb))
case hasAnyFlag(extra, batchCPUFlags...):
if tVal == "" {
tVal = "0"
notes = append(notes, "EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que "+
"llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre")
}
args = append(args, "-tb", tVal)
}
return args, notes
}
// batchCPUFlags : les réglages CPU propres au batch, qu'un -tb absent effacerait.
var batchCPUFlags = []string{"-Cb", "--cpu-mask-batch", "-Crb", "--cpu-range-batch",
"--cpu-strict-batch", "--prio-batch", "--poll-batch"}
// flagValue renvoie la valeur de la DERNIÈRE occurrence de l'un des drapeaux
// (« -t 6 » ou « -t=6 ») — celle que retient le moteur. Vide s'il n'y en a pas.
func flagValue(args []string, flags ...string) string {
v := ""
for i, a := range args {
name, val, hasEq := strings.Cut(a, "=")
for _, f := range flags {
if name != f {
continue
}
if hasEq {
v = val
} else if i+1 < len(args) {
v = args[i+1]
}
}
}
return v
}
// cpusetThreads : sonde de conteneur, Linux seulement (fsys = la racine « / »).
//
// L'auto de llama.cpp se trompe quand le conteneur n'a droit qu'à une partie
// de la machine :
// - cpuset restreint (docker --cpuset-cpus) : common_cpu_get_num_math essaie
// d'épingler un thread sur chaque CPU de l'hôte, échoue hors du cpuset et
// se rabat sur TOUS les cœurs physiques de l'hôte (sysfs n'est pas cloisonné),
// cœurs E compris ;
// - quota CFS (docker --cpus) : invisible dans l'affinité, il étrangle des
// threads qui attendent en boucle active — le pire cas.
//
// On compte alors comme llama.cpp (chaînes thread_siblings distinctes, cœurs E
// de /sys/devices/cpu_atom/cpus écartés), mais sur les seuls CPU permis, plafonné
// par le quota. On ne renvoie un nombre que s'il est PLUS PETIT que ce que le
// moteur aurait pris seul ; à la moindre lecture ratée, rien (le moteur décide).
func cpusetThreads(fsys fs.FS) cpuBudget {
online, err := readCPUList(fsys, "sys/devices/system/cpu/online")
if err != nil || len(online) == 0 {
return cpuBudget{}
}
allowed, err := procAllowedCPUs(fsys)
if err != nil {
return cpuBudget{}
}
allowed = intersectCPUs(allowed, online)
if len(allowed) == 0 {
return cpuBudget{}
}
restricted := len(allowed) < len(online)
quota := cgroupCPUQuota(fsys)
if !restricted && quota == 0 {
return cpuBudget{}
}
// Absent = CPU non hybride : aucun cœur E à écarter.
atom, _ := readCPUList(fsys, "sys/devices/cpu_atom/cpus")
hostAll, err := physicalCores(fsys, online, nil)
if err != nil {
return cpuBudget{}
}
hostMath, err := physicalCores(fsys, online, atom)
if err != nil {
return cpuBudget{}
}
if hostMath == 0 {
hostMath = hostAll
}
var n, engine int
var why string
if restricted {
engine = hostAll // épinglage refusé hors cpuset → repli sur tous les cœurs de l'hôte
if n, err = physicalCores(fsys, allowed, atom); err != nil {
return cpuBudget{}
}
if n == 0 { // conteneur cantonné aux cœurs E : on les prend, faute de mieux
if n, err = physicalCores(fsys, allowed, nil); err != nil {
return cpuBudget{}
}
}
why = fmt.Sprintf("cpuset du conteneur, %d cœurs physiques permis sur %d", n, hostAll)
if len(atom) > 0 {
why += " (cœurs E écartés)"
}
} else {
engine, n = hostMath, hostMath
}
if quota > 0 && quota < n {
n = quota
why = fmt.Sprintf("quota cgroup de %d CPU", quota)
}
if n <= 0 || n >= engine {
return cpuBudget{}
}
return cpuBudget{N: n, Engine: engine, Why: why}
}
// physicalCores compte les cœurs physiques parmi cpus comme le fait
// common_cpu_get_num_physical_cores : une chaîne thread_siblings distincte par
// cœur — juste aussi sur les machines multi-puces, où (package, core_id) peut
// se répéter. Les CPU de exclude (cœurs E) ne comptent pas.
func physicalCores(fsys fs.FS, cpus, exclude []int) (int, error) {
skip := map[int]bool{}
for _, c := range exclude {
skip[c] = true
}
seen := map[string]bool{}
for _, c := range cpus {
if skip[c] {
continue
}
b, err := fs.ReadFile(fsys, fmt.Sprintf("sys/devices/system/cpu/cpu%d/topology/thread_siblings", c))
if err != nil {
return 0, err
}
seen[strings.TrimSpace(string(b))] = true
}
return len(seen), nil
}
// procAllowedCPUs lit Cpus_allowed_list dans /proc/self/status : l'affinité
// réelle du processus, cpuset du conteneur compris.
func procAllowedCPUs(fsys fs.FS) ([]int, error) {
b, err := fs.ReadFile(fsys, "proc/self/status")
if err != nil {
return nil, err
}
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(line, "Cpus_allowed_list:"); ok {
return parseCPUList(v)
}
}
return nil, fmt.Errorf("pas de Cpus_allowed_list")
}
// cgroupCPUQuota renvoie le quota CPU du cgroup arrondi au CPU supérieur
// (au moins 1), 0 s'il n'y en a pas ou s'il est illisible. cgroup v2
// (cpu.max : « max 100000 » ou « 250000 100000 »), sinon v1 (cfs_quota_us,
// -1 = illimité). Seul un conteneur voit son propre cgroup sous /sys/fs/cgroup.
func cgroupCPUQuota(fsys fs.FS) int {
if b, err := fs.ReadFile(fsys, "sys/fs/cgroup/cpu.max"); err == nil {
f := strings.Fields(string(b))
if len(f) == 0 || f[0] == "max" {
return 0
}
period := "100000"
if len(f) > 1 {
period = f[1]
}
return ceilQuota(f[0], period)
}
for _, dir := range []string{"sys/fs/cgroup/cpu", "sys/fs/cgroup/cpu,cpuacct"} {
q, err := fs.ReadFile(fsys, dir+"/cpu.cfs_quota_us")
if err != nil {
continue
}
p, err := fs.ReadFile(fsys, dir+"/cpu.cfs_period_us")
if err != nil {
return 0
}
return ceilQuota(strings.TrimSpace(string(q)), strings.TrimSpace(string(p)))
}
return 0
}
func ceilQuota(quota, period string) int {
q, err1 := strconv.ParseInt(quota, 10, 64)
p, err2 := strconv.ParseInt(period, 10, 64)
if err1 != nil || err2 != nil || q <= 0 || p <= 0 {
return 0
}
return int(max(1, (q+p-1)/p))
}
// readCPUList lit un fichier au format liste du noyau (« 0-3,8-11 »).
func readCPUList(fsys fs.FS, name string) ([]int, error) {
b, err := fs.ReadFile(fsys, name)
if err != nil {
return nil, err
}
return parseCPUList(string(b))
}
// parseCPUList décode « 0-3,8-11 » en liste triée, sans doublon. Une liste
// vide est valide (aucun CPU) ; une syntaxe inconnue est une erreur.
func parseCPUList(s string) ([]int, error) {
set := map[int]bool{}
for _, part := range strings.Split(strings.TrimSpace(s), ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
lo, hi, isRange := strings.Cut(part, "-")
a, err := strconv.Atoi(lo)
if err != nil {
return nil, err
}
b := a
if isRange {
if b, err = strconv.Atoi(hi); err != nil {
return nil, err
}
}
if a < 0 || b < a || b-a > 1<<16 {
return nil, fmt.Errorf("liste de CPU invalide : %q", part)
}
for c := a; c <= b; c++ {
set[c] = true
}
}
out := make([]int, 0, len(set))
for c := range set {
out = append(out, c)
}
sort.Ints(out)
return out, nil
}
func intersectCPUs(a, b []int) []int {
in := map[int]bool{}
for _, c := range b {
in[c] = true
}
var out []int
for _, c := range a {
if in[c] {
out = append(out, c)
}
}
return out
}
+163
View File
@@ -0,0 +1,163 @@
package loki
import (
"fmt"
"reflect"
"testing"
"testing/fstest"
)
// cpuFS fabrique un /proc + /sys minimal : online, l'affinité du processus et,
// pour chaque CPU en ligne, sa chaîne thread_siblings (siblings[cpu]).
func cpuFS(online, allowed string, siblings map[int]string, extra map[string]string) fstest.MapFS {
m := fstest.MapFS{
"sys/devices/system/cpu/online": {Data: []byte(online + "\n")},
"proc/self/status": {Data: []byte("Name:\tllama-server\nCpus_allowed:\tffff\nCpus_allowed_list:\t" + allowed + "\nMems_allowed:\t1\n")},
}
for c, s := range siblings {
m[fmt.Sprintf("sys/devices/system/cpu/cpu%d/topology/thread_siblings", c)] = &fstest.MapFile{Data: []byte(s + "\n")}
}
for k, v := range extra {
m[k] = &fstest.MapFile{Data: []byte(v)}
}
return m
}
// smtPairs : n cœurs à deux threads, frères adjacents (0-1, 2-3…) — la
// numérotation que suppose cpu_count_math_cpus.
func smtPairs(cores, from int) map[int]string {
m := map[int]string{}
for i := 0; i < cores; i++ {
s := fmt.Sprintf("core%d", from/2+i)
m[from+2*i], m[from+2*i+1] = s, s
}
return m
}
// hybrid : 8 cœurs P à deux threads (CPU 0-15) + 8 cœurs E (CPU 16-23).
func hybrid() map[int]string {
m := smtPairs(8, 0)
for c := 16; c < 24; c++ {
m[c] = fmt.Sprintf("ecore%d", c)
}
return m
}
func TestCPUSetThreads(t *testing.T) {
cases := []struct {
name string
fs fstest.MapFS
n int
}{
{"machine entière, sans quota : rien", cpuFS("0-15", "0-15", smtPairs(8, 0), nil), 0},
{"cpuset de 8 threads SMT : 4 cœurs", cpuFS("0-15", "0-7", smtPairs(8, 0), nil), 4},
{
// L'exemple qui tournait mal : tout sauf cpu0 sur un Intel hybride.
// Les cœurs E sont écartés, cpu1 partage son cœur avec cpu0 → 8, pas 15.
"hybride, tout sauf cpu0 : 8 cœurs P",
cpuFS("0-23", "1-23", hybrid(), map[string]string{"sys/devices/cpu_atom/cpus": "16-23\n"}), 8,
},
{
"hybride, cantonné aux cœurs E : on les prend",
cpuFS("0-23", "16-19", hybrid(), map[string]string{"sys/devices/cpu_atom/cpus": "16-23\n"}), 4,
},
{
"quota v2 de 2,5 CPU : arrondi à 3",
cpuFS("0-15", "0-15", smtPairs(8, 0), map[string]string{"sys/fs/cgroup/cpu.max": "250000 100000\n"}), 3,
},
{
"quota v2 illimité : rien",
cpuFS("0-15", "0-15", smtPairs(8, 0), map[string]string{"sys/fs/cgroup/cpu.max": "max 100000\n"}), 0,
},
{
"quota plus large que les cœurs : rien",
cpuFS("0-15", "0-15", smtPairs(8, 0), map[string]string{"sys/fs/cgroup/cpu.max": "1200000 100000\n"}), 0,
},
{
"quota v1 de 2 CPU",
cpuFS("0-15", "0-15", smtPairs(8, 0), map[string]string{
"sys/fs/cgroup/cpu,cpuacct/cpu.cfs_quota_us": "200000\n",
"sys/fs/cgroup/cpu,cpuacct/cpu.cfs_period_us": "100000\n"}), 2,
},
{
"quota v1 illimité (-1) : rien",
cpuFS("0-15", "0-15", smtPairs(8, 0), map[string]string{
"sys/fs/cgroup/cpu/cpu.cfs_quota_us": "-1\n",
"sys/fs/cgroup/cpu/cpu.cfs_period_us": "100000\n"}), 0,
},
{
"cpuset ET quota : le plus petit",
cpuFS("0-15", "0-7", smtPairs(8, 0), map[string]string{"sys/fs/cgroup/cpu.max": "200000 100000"}), 2,
},
{
"topologie illisible : on ne devine pas",
cpuFS("0-15", "0-7", map[int]string{0: "core0"}, nil), 0,
},
{"pas de /proc : rien", fstest.MapFS{"sys/devices/system/cpu/online": {Data: []byte("0-3")}}, 0},
{"liste illisible : rien", cpuFS("0-15", "0-x", smtPairs(8, 0), nil), 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := cpusetThreads(c.fs)
if got.N != c.n {
t.Fatalf("N = %d (%+v), want %d", got.N, got, c.n)
}
if got.N > 0 && (got.Why == "" || got.Engine <= got.N) {
t.Fatalf("budget incomplet : %+v", got)
}
})
}
}
func TestParseCPUList(t *testing.T) {
for _, c := range []struct {
in string
want []int
ok bool
}{
{"0-3,8-9\n", []int{0, 1, 2, 3, 8, 9}, true},
{" 5 ", []int{5}, true},
{"2,0-2", []int{0, 1, 2}, true},
{"", []int{}, true},
{"3-1", nil, false},
{"a", nil, false},
} {
got, err := parseCPUList(c.in)
if (err == nil) != c.ok || (c.ok && !reflect.DeepEqual(got, c.want)) {
t.Errorf("parseCPUList(%q) = %v, %v ; want %v", c.in, got, err, c.want)
}
}
}
func TestThreadCount(t *testing.T) {
for _, c := range []struct {
in string
n int
note bool
}{
{"", 0, false}, {"0", 0, false}, {" 8 ", 8, false}, {"auto", 0, true}, {"-1", 0, true},
} {
n, note := threadCount("THREADS", c.in)
if n != c.n || (note != "") != c.note {
t.Errorf("threadCount(%q) = %d, %q", c.in, n, note)
}
}
}
func TestFlagValue(t *testing.T) {
for _, c := range []struct {
args []string
want string
}{
{nil, ""},
{[]string{"-t", "6"}, "6"},
{[]string{"--threads=7"}, "7"},
{[]string{"-t", "6", "--threads", "8"}, "8"}, // la dernière gagne, comme dans le moteur
{[]string{"-tb", "9"}, ""}, // -tb n'est pas -t
{[]string{"-t"}, ""}, // drapeau orphelin
} {
if got := flagValue(c.args, "-t", "--threads"); got != c.want {
t.Errorf("flagValue(%q) = %q, want %q", c.args, got, c.want)
}
}
}
File diff suppressed because it is too large. Load diff
+379
View File
@@ -0,0 +1,379 @@
package loki
import (
"os"
"path/filepath"
"strings"
"testing"
)
func strataArgs(t *testing.T, cfg map[string]any) []string {
t.Helper()
var out []string
for _, a := range cfg["args"].([]any) {
out = append(out, a.(string))
}
return out
}
func argAfter(args []string, flag string) string {
for i := 0; i+1 < len(args); i++ {
if args[i] == flag {
return args[i+1]
}
}
return ""
}
func TestStrataBuildConfigApplyTuning(t *testing.T) {
base := map[string]any{
"exe": "/x/engine-cuda12/strata",
"args": []any{"--pack", "/p", "--prefill", "8192", "--max-context", "131072"},
"port": 8080.0,
}
out, err := strataBuildConfig(base, map[string]string{"PORT": "8081", "CTX": "65536"}, "k", "ram")
if err != nil {
t.Fatal(err)
}
a := strataArgs(t, out)
for flag, want := range map[string]string{"--prefill": "16384", "--short-read": "150", "--prompt-cache-root": "256", "--max-context": "65536", "--pack": "/p"} {
if got := argAfter(a, flag); got != want {
t.Errorf("%s = %q, attendu %q (args %v)", flag, got, want, a)
}
}
if strings.Count(strings.Join(a, " "), "--prefill") != 1 {
t.Errorf("--prefill dupliqué : %v", a)
}
if strataHasArg(a, "--remote-expert-opt") || strataHasArg(a, "--expert-cache-device1") {
t.Errorf("aide 2e carte sans 2e carte : %v", a)
}
if out["port"] != 8081 || out["api_key"] != "k" || out["host"] != "127.0.0.1" {
t.Errorf("port/clé/hôte : %v %v %v", out["port"], out["api_key"], out["host"])
}
// la config de l'installeur n'est pas modifiée
if base["args"].([]any)[3] != "8192" {
t.Error("la config de base a été modifiée")
}
}
func TestStrataBuildConfigHelperGPU(t *testing.T) {
base := map[string]any{"args": []any{"--pack", "/p", "--mmap-experts", "--vram-reserve-mib", "700"}, "gpu": 1.0,
"vision": map[string]any{"exe": "v", "gpu": true}}
out, err := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "", "ram")
if err != nil {
t.Fatal(err)
}
a := strataArgs(t, out)
if argAfter(a, "--expert-cache-device1") != "auto" || !strataHasArg(a, "--remote-expert-opt") {
t.Errorf("aide 2e carte absente : %v", a)
}
if strataHasArg(a, "--vram-reserve-mib") || strataHasArg(a, "700") {
t.Errorf("réserve de VRAM gardée : %v", a)
}
if _, has := out["api_key"]; has {
t.Error("api_key posée sans clé")
}
if _, has := out["gpu"]; has {
t.Error("clé gpu gardée avec une carte d'aide (le serveur en ferait un --layer-split)")
}
if out["vision"].(map[string]any)["cuda_device"] != 0 {
t.Errorf("encodeur pas sur la carte d'aide : %v", out["vision"])
}
if out["env"].(map[string]any)["STRATA_PLE_BATCH"] != "0" {
t.Errorf("STRATA_PLE_BATCH absent : %v", out["env"])
}
}
func TestStrataCudaDevices(t *testing.T) {
cases := []struct {
main, helper, want string
}{
{"1", "0", "1,0"},
{"0", "-1", "0"},
{"0", "", "0"},
{"", "", ""},
}
for _, c := range cases {
got := strataCudaDevices(map[string]string{"STRATA_MAIN_GPU": c.main, "STRATA_HELPER_GPU": c.helper})
if got != c.want {
t.Errorf("main=%q helper=%q : %q, attendu %q", c.main, c.helper, got, c.want)
}
}
}
func TestStrataRecommend(t *testing.T) {
// la machine de test : 5060 Ti 16 Go + 3070 8 Go, 47 Go de RAM, disque large
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, AvailGB: 47, DiskFreeGB: 400,
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}}
if got := strataRecommend("swift", env); got != "IQ3_XXS" {
t.Errorf("swift : %q, attendu IQ3_XXS", got)
}
// IQ3_S demande de garder 50 Go d'experts en RAM : pas sur 47 Go
if got := strataRecommend("qwen", env); got != "IQ3_XXS" {
t.Errorf("qwen 47 Go : %q, attendu IQ3_XXS", got)
}
f := strataFitFor(strataQuants["IQ3_XXS"], env, 0)
// trop juste pour tout garder en RAM : experts lus depuis le disque (le mode
// hors RAM demande un experts.bin, qu'une installation neuve n'a pas)
if !f.OK || !f.Mmap || f.Drop {
t.Errorf("IQ3_XXS sur 47 Go : ok=%v mmap=%v drop=%v (attendu ok, disque)", f.OK, f.Mmap, f.Drop)
}
// 64 Go : IQ3_S tient en RAM
env.RAMGB, env.AvailGB = 64, 64
if got := strataRecommend("qwen", env); got != "IQ3_S" {
t.Errorf("qwen 64 Go : %q, attendu IQ3_S", got)
}
// 16 Go de RAM et une seule carte de 8 Go : rien ne tient
small := strataEnv{Supported: true, Main: 0, Helper: -1, RAMGB: 16, DiskFreeGB: 400,
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}}}
if got := strataRecommend("swift", small); got != "" {
t.Errorf("petite machine : %q, attendu aucune recommandation", got)
}
// disque trop petit
env.DiskFreeGB = 50
if f := strataFitFor(strataQuants["Q2_0"], env, 0); f.OK {
t.Error("Q2_0 accepté avec 50 Go de disque")
}
}
func TestStrataPresetName(t *testing.T) {
if got := strataPresetName("swift", "IQ3_XXS"); got != "STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS" {
t.Errorf("%q", got)
}
if got := strataPresetName("qwen", "Q2_0"); got != "STRATA FLASH NEXT CLASSIQUE Q2_0" {
t.Errorf("%q", got)
}
}
func TestStrataFitCountsExistingData(t *testing.T) {
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, DiskFreeGB: 32,
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}}
if f := strataFitFor(strataQuants["IQ3_XXS"], env, 120); !f.OK {
t.Errorf("données déjà présentes, refusé quand même : %s", f.Why)
}
if f := strataFitFor(strataQuants["IQ3_XXS"], env, 0); f.OK {
t.Error("32 Go libres sans données : accepté")
}
}
func TestStrataBuildConfigPresetOverrides(t *testing.T) {
base := map[string]any{"args": []any{"--kv", "int8", "--spec", "4"}}
out, _ := strataBuildConfig(base, map[string]string{"STRATA_KV": "fp16", "STRATA_SPEC": "3"}, "", "ram")
a := strataArgs(t, out)
if argAfter(a, "--kv") != "fp16" || argAfter(a, "--spec") != "3" {
t.Errorf("%v", a)
}
}
func TestStrataDetailsFor(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
base := filepath.Join(home, "base.json")
if err := os.WriteFile(base, []byte(`{"args":["--pack","/p","--kv","int8","--spec","4","--max-context","131072"],"vision":{"exe":"v"}}`), 0o644); err != nil {
t.Fatal(err)
}
_ = os.MkdirAll(presetsDir(), 0o755)
preset := "ENGINE=strata\nSTRATA_CONFIG=" + base + "\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\nSTRATA_MMAP=1\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\n"
if err := os.WriteFile(filepath.Join(presetsDir(), "X.env"), []byte(preset), 0o644); err != nil {
t.Fatal(err)
}
env := strataEnv{GPUs: []strataGPU{{Index: 0, Name: "NVIDIA GeForce RTX 3070", VRAMGB: 8}, {Index: 1, Name: "NVIDIA GeForce RTX 5060 Ti", VRAMGB: 16}}}
d := strataDetailsFor("X", env)
if d == nil {
t.Fatal("pas de détails")
}
if d.KV != "fp16" || d.KVResident != "32768" || d.Spec != "3" || d.Ctx != "131072" || !d.Mmap ||
d.MainGPU != "RTX 5060 Ti (16 Go)" || d.HelperGPU != "RTX 3070 (8 Go)" || d.VisionGPU != "RTX 3070 (8 Go)" ||
d.ShortRead != "150" || d.CacheRoot != "256" || d.Prefill != "16384" {
t.Errorf("%+v", *d)
}
}
func TestStrataHaveGBPerChoice(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
write := func(rel string, n int) {
p := filepath.Join(strataDataDir(), rel)
_ = os.MkdirAll(filepath.Dir(p), 0o755)
if err := os.WriteFile(p, make([]byte, n), 0o644); err != nil {
t.Fatal(err)
}
}
write("models/swift-IQ3_XXS/a.gguf", 3000)
write("packs/swift-iq3_xxs/experts.bin", 2000)
write("mtp/rt/x", 500)
write("models/IQ2_XS/b.gguf", 7000) // un autre choix : ne compte pas pour swift IQ3_XXS
if got := strataHaveGB("swift", "IQ3_XXS") * 1e9; got != 5500 {
t.Errorf("swift IQ3_XXS : %v octets, attendu 5500", got)
}
if got := strataHaveGB("swift", "IQ2_XS") * 1e9; got != 500 {
t.Errorf("swift IQ2_XS : %v octets, attendu 500 (seulement la tête MTP commune)", got)
}
}
func TestStrataApplySettings(t *testing.T) {
in := "# NAME=x\nENGINE=strata\nCTX=131072\nSTRATA_KV=fp16\nSTRATA_SPEC=3\nSTRATA_HELPER_GPU=0\n"
out, err := strataApplySettings(in, strataSettingsReq{Ctx: 65536, KV: "int8", Spec: 4, Vision: false, Helper: false})
if err != nil {
t.Fatal(err)
}
cfg := parseEnv(out)
if cfg["CTX"] != "65536" || cfg["STRATA_KV"] != "int8" || cfg["STRATA_SPEC"] != "4" || cfg["STRATA_VISION"] != "0" ||
cfg["STRATA_HELPER"] != "0" || cfg["STRATA_HELPER_GPU"] != "0" || cfg["ENGINE"] != "strata" {
t.Errorf("%q", out)
}
if strings.Count(out, "CTX=") != 1 {
t.Errorf("CTX dupliqué : %q", out)
}
if strataHelperOn(cfg) || strataCudaDevices(map[string]string{"STRATA_MAIN_GPU": "1", "STRATA_HELPER_GPU": "0", "STRATA_HELPER": "0"}) != "1" {
t.Error("carte d'aide coupée mais encore utilisée")
}
base := map[string]any{"args": []any{"--vision", "--pack", "/p"}, "vision": map[string]any{"exe": "v"}}
b, _ := strataBuildConfig(base, cfg, "", "ram")
if strataHasArg(strataArgs(t, b), "--vision") || b["vision"] != nil || strataHasArg(strataArgs(t, b), "--expert-cache-device1") {
t.Errorf("vision / aide coupées mais présentes : %v", b)
}
for _, bad := range []strataSettingsReq{{Ctx: 1000, KV: "fp16", Spec: 3}, {Ctx: 32768, KV: "q2", Spec: 3}, {Ctx: 32768, KV: "fp16", Spec: 9}} {
if _, err := strataApplySettings(in, bad); err == nil {
t.Errorf("accepté : %+v", bad)
}
}
}
func TestEngineOf(t *testing.T) {
cases := map[string]string{"": engineLlamacpp, "llamacpp": engineLlamacpp, "strata": engineStrata, " Strata ": engineStrata, "moe": engineStrata, "autre": engineLlamacpp}
for in, want := range cases {
if got := engineOf(map[string]string{"ENGINE": in}); got != want {
t.Errorf("ENGINE=%q : %q, attendu %q", in, got, want)
}
}
}
func TestModelLoadErrorStrata(t *testing.T) {
ok := strataServeMarker + " (paquet AJEAN MoE 1.0)\nloading the model (the first start takes a minute or two) ...\nready: http://127.0.0.1:8080/v1 (OpenAI: /v1/chat/completions)\n"
if got := modelLoadErrorFrom(ok); got != "" {
t.Errorf("Strata prêt signalé en échec : %q", got)
}
crash := strataServeMarker + "\nTraceback (most recent call last):\n File \"server.py\", line 1\nRuntimeError: boom\n"
if got := modelLoadErrorFrom(crash); !strings.Contains(got, "Strata") {
t.Errorf("trace Python non signalée : %q", got)
}
// une trace d'un lancement PRÉCÉDENT ne compte pas
if got := modelLoadErrorFrom(crash + strataServeMarker + "\nloading the model ...\n"); got != "" {
t.Errorf("ancienne trace prise pour l'échec en cours : %q", got)
}
}
// Où vont les experts : décidé sur la RAM DISPONIBLE. Vécu : 62 Go dont 18 pris
// ailleurs, mode « hors RAM » choisi sur la RAM totale, moteur tué par le noyau.
func TestStrataResolveExperts(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
pack := filepath.Join(home, "pack")
_ = os.MkdirAll(pack, 0o755)
args := []string{"--pack", pack}
cfg := map[string]string{"STRATA_CONFIG": "/x/strata-swift-iq3_xxs.json", "STRATA_HELPER_GPU": "1", "STRATA_DROP": "1"}
// IQ3_XXS : 42,9 Go d'experts
cases := []struct {
experts string
avail float64
want string
}{
{"", 44, "disk"}, // le cas vécu : 44 Go disponibles, pas de experts.bin
{"", 60, "ram"}, // assez de RAM pour tout charger avec la marge
{"auto", 52.8, "disk"},
{"disk", 60, "disk"}, // choix de l'utilisateur
{"ram", 20, "ram"}, // choix de l'utilisateur, même à l'étroit
}
for _, c := range cases {
cfg["STRATA_EXPERTS"] = c.experts
if got := strataResolveExperts(cfg, args, c.avail, 12); got != c.want {
t.Errorf("experts=%q dispo=%.0f : %s, attendu %s", c.experts, c.avail, got, c.want)
}
}
// experts.bin présent (moteur du paquet) : le mode hors RAM et le mappage deviennent possibles
_ = os.WriteFile(filepath.Join(pack, "experts.bin"), []byte("x"), 0o644)
_ = os.MkdirAll(filepath.Join(strataSrcDir(), "engine-cuda12"), 0o755)
_ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644)
cfg["STRATA_EXPERTS"] = ""
if got := strataResolveExperts(cfg, args, 44, 12); got != "drop" {
t.Errorf("44 Go + carte d'appoint 12 Go + experts.bin : %s, attendu drop", got)
}
if got := strataResolveExperts(cfg, args, 30, 12); got != "arena" {
t.Errorf("30 Go + experts.bin : %s, attendu arena", got)
}
cfg["STRATA_HELPER"] = "0"
if got := strataResolveExperts(cfg, args, 44, 12); got != "arena" {
t.Errorf("carte d'appoint coupée : %s, attendu arena", got)
}
cfg["STRATA_EXPERTS"] = "disk"
if got := strataResolveExperts(cfg, args, 60, 12); got != "arena" {
t.Errorf("disque demandé avec experts.bin : %s, attendu arena", got)
}
// quant inconnu : on ne parie pas sur la RAM
if got := strataResolveExperts(map[string]string{"STRATA_CONFIG": "/x/autre.json"}, nil, 500, 0); got != "disk" {
t.Errorf("quant inconnu : %s, attendu disk", got)
}
}
func TestStrataBuildConfigModes(t *testing.T) {
base := map[string]any{"args": []any{"--pack", "/p", "--resident-experts", "--pcie-frac", "0.3"}, "env": map[string]any{"X": "1"}}
cfg := map[string]string{"STRATA_HELPER_GPU": "1"}
env := func(m map[string]any) map[string]any { return m["env"].(map[string]any) }
out, _ := strataBuildConfig(base, cfg, "", "disk")
a := strataArgs(t, out)
if !strataHasArg(a, "--mmap-experts") || strataHasArg(a, "--resident-experts") || argAfter(a, "--pcie-frac") != "0" ||
env(out)["STRATA_ARENA_MMAP"] != nil || env(out)["X"] != "1" {
t.Errorf("disk : %v %v", a, env(out))
}
out, _ = strataBuildConfig(base, cfg, "", "ram")
a = strataArgs(t, out)
if strataHasArg(a, "--mmap-experts") || strataHasArg(a, "--resident-experts") || env(out)["STRATA_ARENA_MMAP"] != nil || env(out)["STRATA_REMOTE_DROP"] != nil {
t.Errorf("ram : %v %v", a, env(out))
}
out, _ = strataBuildConfig(base, cfg, "", "arena")
if a = strataArgs(t, out); argAfter(a, "--pcie-frac") != "0" || env(out)["STRATA_ARENA_MMAP"] != "1" {
t.Errorf("arena : %v %v", a, env(out))
}
out, _ = strataBuildConfig(base, cfg, "", "drop")
if a = strataArgs(t, out); strataHasArg(a, "--pcie-frac") || env(out)["STRATA_REMOTE_DROP"] != "1" {
t.Errorf("drop : la part PCIe doit être automatique : %v %v", a, env(out))
}
// drop sans carte d'appoint : rien de spécial, c'est le mode ram
out, _ = strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "-1"}, "", "drop")
if env(out)["STRATA_REMOTE_DROP"] != nil {
t.Error("mode hors RAM sans carte d'appoint")
}
}
func TestStrataApplySettingsExperts(t *testing.T) {
in := "ENGINE=strata\nSTRATA_EXPERTS=auto\n"
r := strataSettingsReq{Ctx: 131072, KV: "fp16", Spec: 3, Experts: "disk"}
out, err := strataApplySettings(in, r)
if err != nil || parseEnv(out)["STRATA_EXPERTS"] != "disk" {
t.Fatalf("%v %q", err, out)
}
r.Experts = "nvme"
if _, err := strataApplySettings(in, r); err == nil {
t.Error("placement inconnu accepté")
}
r.Experts = ""
if out, _ := strataApplySettings(in, r); parseEnv(out)["STRATA_EXPERTS"] != "auto" {
t.Errorf("vide doit laisser le choix en place : %q", out)
}
}
// Vécu : bascule de Strata vers llama-server, l'encodeur de vision ne trouve
// plus de VRAM. La pile d'appels cite « load_model » : elle ne doit pas passer
// pour une nouvelle tentative qui effacerait la cause, écrite juste avant.
func TestModelLoadErrorOOMBeforeBacktrace(t *testing.T) {
log := "0.00.247.285 I srv load_model: loading model '/data/models/Swift.gguf'\n" +
"4.47.957.764 E ggml_backend_cuda_buffer_type_alloc_buffer: allocating 884.62 MiB on device 0: cudaMalloc failed: out of memory\n" +
"/app/ggml/src/ggml-backend.cpp:328: GGML_ASSERT(buffer) failed\n" +
"/data/engine/libmtmd.so.0(mtmd_init_from_file+0x31)[0x14bd4fea4c01]\n" +
"/data/engine/libllama-server-impl.so(_ZN19server_context_impl10load_modelER13common_params+0x7ff)[0x14bd50b9f1ff]\n" +
"/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0x8b)[0x14bd5059a28b]\n"
if got := modelLoadErrorFrom(log); !strings.Contains(got, "mémoire GPU insuffisante") {
t.Errorf("cause mémoire perdue derrière la pile d'appels : %q", got)
}
}
File diff suppressed because it is too large. Load diff
+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()
}
+477
View File
@@ -0,0 +1,477 @@
package loki
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// Verrou de l'optimiseur (loki tune, bouton « Optimiser »).
//
// Pendant une optimisation, la VRAM appartient au moteur d'ESSAI : le vrai
// moteur est arrêté, et le relancer à côté (bascule de preset, enregistrement du
// preset actif, choix des GPU, mise à jour du moteur, « recharger » la VRAM…)
// chargerait deux modèles sur des cartes qui n'en tiennent qu'un — un OOM certain,
// et une mesure faussée. Un drapeau en mémoire ne suffit pas : « loki tune » en
// ligne de commande et le processus web sont deux processus distincts. Le verrou
// est donc un FICHIER de LOKI_HOME, créé de façon exclusive, que tout le monde
// lit :
//
// - serviceAction("start"/"restart") le consulte sur toutes les plateformes —
// aucun démarrage du moteur ne passe à côté ;
// - le chat, les tâches planifiées (qui attendent), les jobs du mode code, les
// clients /v1, le bench, la bascule et l'enregistrement de preset refusent
// avec une phrase claire.
//
// Le fichier porte l'identité du processus qui le tient (PID, instant de
// démarrage, binaire) et celle de l'essai en cours. Un verrou dont le
// propriétaire est mort (Loki redémarré en plein essai) est périmé : avant tout
// démarrage du moteur, l'essai orphelin est arrêté — seulement si PID, instant
// de démarrage ET binaire concordent, jamais sur la foi d'un PID recyclé — puis
// le fichier est retiré.
const tuneLockName = "tune.lock"
// tuneLockFreshness : un verrou illisible plus jeune que ça est en cours
// d'écriture, pas abandonné.
const tuneLockFreshness = 10 * time.Second
var errTuneBusy = errors.New("optimisation en cours (loki tune) : le moteur est réservé aux essais — " +
"attends la fin ou annule-la")
// tuneProc : un processus désigné sans ambiguïté.
type tuneProc struct {
PID int `json:"pid"`
Start string `json:"start,omitempty"` // procIdentity ; vide = illisible au moment de l'écrire
Exes []string `json:"exes,omitempty"` // binaires admis (un « loki serve » devient llama-server par exec)
}
// tuneOwner : le contenu du verrou.
type tuneOwner struct {
Owner tuneProc `json:"owner"`
Via string `json:"via"` // « web » ou « cli »
Phase string `json:"phase"` // « essais » ou « application »
Since int64 `json:"since"`
// Nonce : distingue deux verrous pris par le même processus dans la même
// seconde (tuneHeldKey). Absent des verrous d'une version d'avant : 0.
Nonce int64 `json:"nonce,omitempty"`
Trial *tuneProc `json:"trial,omitempty"`
// MainWasActive : le vrai moteur tournait avant l'optimisation. Un verrou
// périmé trouvé au démarrage du processus web le relance.
MainWasActive bool `json:"main_was_active"`
// Application en cours (phase « application ») : la version d'avant du
// preset, sauvée avant toute écriture. Un verrou périmé dans cette phase
// veut dire que l'application n'a jamais été vérifiée : elle est défaite.
Backup string `json:"backup,omitempty"`
PresetID string `json:"preset_id,omitempty"`
PresetName string `json:"preset_name,omitempty"`
}
func tuneLockPath() string { return filepath.Join(LokiHome(), tuneLockName) }
// tuneProcAlive dit si p désigne encore le MÊME processus. Variable pour les
// tests.
var tuneProcAlive = func(p tuneProc) bool {
start, exe, ok := procIdentity(p.PID)
if !ok {
return false
}
if p.Start != "" && start != p.Start {
return false
}
if exe == "" || len(p.Exes) == 0 {
return true
}
for _, e := range p.Exes {
if sameExe(e, exe) {
return true
}
}
return false
}
// sameExe compare deux binaires par leur nom de fichier (sans .exe, sans
// casse) : /proc/<pid>/exe résout les liens symboliques, ps n'en donne parfois
// que le nom.
func sameExe(a, b string) bool {
norm := func(s string) string {
s = strings.TrimSuffix(strings.TrimSpace(s), " (deleted)") // binaire remplacé (Linux)
s = strings.ToLower(filepath.Base(strings.ReplaceAll(s, `\`, "/")))
return strings.TrimSuffix(s, ".exe")
}
return a != "" && b != "" && norm(a) == norm(b)
}
// selfProc : ce processus, tel que le verrou le note.
func selfProc() tuneProc {
p := tuneProc{PID: os.Getpid()}
if start, exe, ok := procIdentity(p.PID); ok {
p.Start = start
if exe != "" {
p.Exes = []string{exe}
}
}
if self, err := os.Executable(); err == nil && len(p.Exes) == 0 {
p.Exes = []string{self}
}
return p
}
func readTuneOwner(path string) (tuneOwner, bool) {
var o tuneOwner
b, err := os.ReadFile(path)
if err != nil || json.Unmarshal(b, &o) != nil || o.Owner.PID <= 0 {
return tuneOwner{}, false
}
return o, true
}
// tuneActive : une optimisation tient-elle le moteur, dans ce processus ou dans
// un autre ? Un verrou périmé (propriétaire mort) ne compte pas.
func tuneActive() (tuneOwner, bool) {
o, ok := readTuneOwner(tuneLockPath())
if !ok {
return tuneOwner{}, false
}
return o, tuneOwnerAlive(o)
}
// tuneHeld : verrous pris par CE processus et pas encore rendus (tuneHeldKey).
var tuneHeld sync.Map
// tuneFileMu sérialise, DANS CE PROCESSUS, les opérations sur le fichier de
// verrou : prise, réécriture, rendu, et le déplacement-relecture du ménage
// (tuneReapStale). Sans lui, le ménage pouvait déplacer un verrou vivant
// pendant que release() le cherchait : release, ne le trouvant plus, rendait
// l'inscription ; le ménage le remettait ensuite en place, désormais orphelin,
// et le ménage suivant le prenait pour une optimisation interrompue (moteur
// relancé, preset « rétabli »). Vu en CI : TestTuneRecoverNeverReapsOwnLock.
var tuneFileMu sync.Mutex
func tuneHeldKey(o tuneOwner) string { return fmt.Sprintf("%s|%d|%d", o.Via, o.Since, o.Nonce) }
// tuneOwnerAlive : le propriétaire d'un verrou vit-il encore ? Un verrou qui
// porte NOTRE PID sans être l'un des nôtres vient d'un processus mort dont le
// PID nous est échu (redémarrage) : périmé, quoi que dise son identité.
func tuneOwnerAlive(o tuneOwner) bool {
if o.Owner.PID == os.Getpid() {
if _, ours := tuneHeld.Load(tuneHeldKey(o)); !ours {
return false
}
}
return tuneProcAlive(o.Owner)
}
// tuneGuard : nil si rien ne tient le moteur, errTuneBusy sinon.
func tuneGuard() error {
if _, busy := tuneActive(); busy {
return errTuneBusy
}
return nil
}
// tuneDenyHTTP répond 409 et rend true quand une optimisation tient le moteur.
func tuneDenyHTTP(w http.ResponseWriter) bool {
if err := tuneGuard(); err != nil {
sendJSON(w, http.StatusConflict, map[string]any{"ok": false, "tuning": true, "error": err.Error()})
return true
}
return false
}
// tuneLock : le verrou tenu par CE processus.
type tuneLock struct {
mu sync.Mutex
path string
owner tuneOwner
held bool // inscrit dans tuneHeld, jusqu'à release
}
// tuneLockAcquire prend le verrou, ou dit qui le tient. Un verrou périmé est
// d'abord écarté (tuneReapStale), essai orphelin compris.
func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
path := tuneLockPath()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
l := &tuneLock{path: path, owner: tuneOwner{Owner: selfProc(), Via: via, Phase: "essais",
Since: time.Now().Unix(), Nonce: time.Now().UnixNano(), MainWasActive: mainActive}}
// Inscrit comme nôtre AVANT que le fichier n'existe : la reprise du
// processus web (tuneRecoverWatch, toutes les 30 s) tient pour mort un
// verrou qui porte notre PID sans être inscrit, et l'écarterait entre la
// création et l'inscription.
key := tuneHeldKey(l.owner)
tuneHeld.Store(key, true)
for attempt := 0; attempt < 3; attempt++ {
tuneFileMu.Lock()
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err == nil {
b, _ := json.Marshal(l.owner)
_, werr := f.Write(b)
cerr := f.Close()
if werr != nil || cerr != nil {
_ = os.Remove(path)
tuneHeld.Delete(key)
tuneFileMu.Unlock()
return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr))
}
l.held = true
tuneFileMu.Unlock()
return l, nil
}
tuneFileMu.Unlock()
if !os.IsExist(err) {
tuneHeld.Delete(key)
return nil, err
}
if o, busy := tuneActive(); busy {
tuneHeld.Delete(key)
return nil, fmt.Errorf("une optimisation tourne déjà (%s, PID %d, depuis %s)", o.Via, o.Owner.PID,
time.Unix(o.Since, 0).Format("15:04"))
}
tuneReapStale()
}
tuneHeld.Delete(key)
return nil, errors.New("verrou de l'optimiseur indisponible : réessaie")
}
// write réécrit le verrou d'un bloc (fichier voisin puis renommage) : un
// lecteur ne voit jamais un JSON à moitié écrit.
func (l *tuneLock) write() error {
tuneFileMu.Lock()
defer tuneFileMu.Unlock()
return l.writeLocked()
}
// writeLocked : write, tuneFileMu déjà tenu par l'appelant.
func (l *tuneLock) writeLocked() error {
b, err := json.Marshal(l.owner)
if err != nil {
return err
}
tmp := l.path + ".tmp-" + strconv.Itoa(os.Getpid())
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
// Windows refuse de remplacer un fichier qu'un autre processus lit à cet
// instant (tuneGuard d'une requête) : on réessaie un peu avant de renoncer.
for i := 0; ; i++ {
err := os.Rename(tmp, l.path)
if err == nil || i == 4 {
if err != nil {
_ = os.Remove(tmp)
}
return err
}
time.Sleep(50 * time.Millisecond)
}
}
// setTrial note l'essai en cours (nil : aucun), AVANT qu'il ne charge quoi que
// ce soit d'important : c'est ce qui permet à un Loki redémarré de l'arrêter.
func (l *tuneLock) setTrial(p *tuneProc) {
l.mu.Lock()
defer l.mu.Unlock()
l.owner.Trial = p
_ = l.write()
}
func (l *tuneLock) setPhase(phase string) {
l.mu.Lock()
defer l.mu.Unlock()
l.owner.Phase = phase
_ = l.write()
}
// setBackup note la sauvegarde du preset avant son écriture (tuneApply).
func (l *tuneLock) setBackup(backup, id, name string) {
l.mu.Lock()
defer l.mu.Unlock()
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = backup, id, name
_ = l.write()
}
// release rend le verrou — seulement s'il est encore le nôtre.
//
// La reprise du processus web (tuneRecoverWatch) tourne toutes les 30 s,
// pendant que ce même processus prend et rend ses verrous. Elle tient pour
// mort un verrou à notre PID qui n'est plus inscrit (tuneHeld) : si le fichier
// survivait à l'inscription — sous Windows, un fichier qu'un autre lit à cet
// instant ne se supprime pas —, elle l'écarterait, et en phase « application »
// défairait le preset tout juste vérifié (tuneUndoApply), ou relancerait le
// moteur. Le verrou est donc d'abord réécrit NEUTRE (rien à défaire, rien à
// relancer, aucun essai), puis retiré, et l'inscription ne part qu'après.
func (l *tuneLock) release() {
l.mu.Lock()
defer l.mu.Unlock()
tuneFileMu.Lock()
defer tuneFileMu.Unlock()
if l.held {
// Un fichier absent un instant (renommé par un ménage qui le remet en
// place, voir tuneReapStale) n'est pas un fichier rendu : on relit.
ours := false
for i := 0; i < 5; i++ {
if o, ok := readTuneOwner(l.path); ok {
ours = sameTuneLock(o, l.owner)
break
}
time.Sleep(20 * time.Millisecond)
}
if ours {
l.owner.Phase, l.owner.Trial, l.owner.MainWasActive = "rendu", nil, false
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = "", "", ""
_ = l.writeLocked()
for i := 0; i < 5; i++ {
if err := os.Remove(l.path); err == nil || os.IsNotExist(err) {
break
}
time.Sleep(20 * time.Millisecond)
}
}
l.held = false
tuneHeld.Delete(tuneHeldKey(l.owner))
return
}
if o, ok := readTuneOwner(l.path); ok && sameTuneLock(o, l.owner) {
_ = os.Remove(l.path)
}
}
// tuneReapStale écarte un verrou dont le propriétaire est mort : l'essai qu'il
// désigne est arrêté s'il est encore là (identité complète vérifiée), puis le
// fichier disparaît. Rend MainWasActive du verrou écarté (false sans verrou
// périmé). Le renommage d'abord : de deux processus qui trouvent le même verrou
// périmé, un seul l'emporte, et aucun ne retire un verrou neuf posé entre-temps.
func tuneReapStale() (mainWasActive bool) {
o, ok := tuneReapClaim()
if !ok {
return false
}
if o.Trial != nil && tuneProcAlive(*o.Trial) {
fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID)
tuneKillTree(o.Trial.PID, nil)
}
tuneUndoApply(o)
return o.MainWasActive
}
// tuneReapClaim : la partie de tuneReapStale qui touche au fichier, sous
// tuneFileMu — lecture, déplacement, relecture, remise en place. Rend le
// verrou périmé effectivement retiré (ok=false : rien à écarter).
func tuneReapClaim() (tuneOwner, bool) {
tuneFileMu.Lock()
defer tuneFileMu.Unlock()
path := tuneLockPath()
o, ok := readTuneOwner(path)
if !ok {
// Illisible (écriture interrompue) : périmé lui aussi, s'il est là —
// mais pas tout juste créé : entre sa création exclusive et l'écriture de
// son contenu, un verrou neuf est vide quelques instants, et le retirer
// laisserait passer une seconde optimisation ou un démarrage du moteur.
if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > tuneLockFreshness {
_ = os.Remove(path)
}
return tuneOwner{}, false
}
if tuneOwnerAlive(o) {
return tuneOwner{}, false
}
stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano())
if os.Rename(path, stale) != nil {
return tuneOwner{}, false
}
defer os.Remove(stale)
// Entre la lecture et le renommage, le verrou lu a pu être rendu et un
// neuf, vivant, pris à sa place : c'est alors CELUI-LÀ qu'on vient de
// déplacer. La reprise du processus web tournant toutes les 30 s, pendant
// que ce même processus prend et rend ses verrous, ce n'est plus un cas
// d'école. On relit donc ce qu'on a déplacé ; un autre verrou est remis en
// place (un lien : jamais par-dessus un verrou posé entre-temps).
if got, ok := readTuneOwner(stale); !ok || !sameTuneLock(got, o) || tuneOwnerAlive(got) {
if err := os.Link(stale, path); err != nil {
fmt.Fprintf(os.Stderr, "[loki tune] verrou vivant déplacé par erreur, remise en place impossible : %v\n", err)
}
return tuneOwner{}, false
}
return o, true
}
// sameTuneLock : a et b désignent-ils la même prise de verrou (propriétaire et
// instant de la prise), quelle que soit sa phase ?
func sameTuneLock(a, b tuneOwner) bool {
return a.Owner.PID == b.Owner.PID && a.Owner.Start == b.Owner.Start && tuneHeldKey(a) == tuneHeldKey(b)
}
// tuneUndoApply : une application interrompue avant sa vérification (Loki tué
// pendant la sonde) remet la version d'avant du preset — c'est ce que la
// vérification aurait fait en échouant. Sans sauvegarde notée, rien.
func tuneUndoApply(o tuneOwner) {
if o.Phase != "application" || o.Backup == "" || o.PresetID == "" {
return
}
b, err := os.ReadFile(o.Backup)
if err != nil {
fmt.Fprintf(os.Stderr, "[loki tune] application interrompue : sauvegarde illisible (%v)\n", err)
return
}
if _, _, err := SavePresetApplying(o.PresetID, o.PresetName, string(b)); err != nil {
fmt.Fprintf(os.Stderr, "[loki tune] application interrompue : retour impossible (%v) — l'ancienne version est dans %s\n", err, o.Backup)
return
}
fmt.Fprintf(os.Stderr, "[loki tune] application interrompue avant sa vérification : ancienne version du preset rétablie\n")
}
// tuneRecoverStale : une optimisation interrompue (Loki redémarré en plein
// essai, « loki tune » tué par kill -9) a laissé le vrai moteur arrêté et peut-
// être un essai en VRAM. On arrête l'essai, puis on relance le moteur s'il
// tournait avant — ce que l'optimisation aurait fait en finissant. Sans
// verrou, un simple stat : rien d'autre n'est lu.
func tuneRecoverStale() {
if _, err := os.Stat(tuneLockPath()); err != nil {
return
}
if tuneReapStale() {
tuneRecoverStart()
}
}
// tuneRecoverStart relance le moteur après une optimisation interrompue, sauf
// preset externe, moteur déjà reparti ou configuration incomplète. Variable
// pour les tests.
var tuneRecoverStart = func() {
if externalActive() || serviceIsActive() {
return
}
if err := preflightEngine(); err != nil {
return
}
fmt.Println(dim("[loki tune] optimisation interrompue : relance du moteur"))
// serviceAction, pas serviceActionOS : la reprise tourne désormais toutes
// les 30 s, et une optimisation neuve a pu prendre le verrou entre le
// ménage et ce démarrage — tuneGuard le voit, et le moteur reste arrêté
// pour elle.
_ = serviceAction("start")
}
// tuneRecoverEvery : la période de la reprise dans le processus web.
const tuneRecoverEvery = 30 * time.Second
// tuneRecoverWatch : au démarrage du processus web, puis à chaque tic. Le
// démarrage seul ne suffisait pas : un « loki tune » en ligne de commande tué
// sans ses defers (kill -9, terminal perdu) laissait le moteur arrêté jusqu'au
// prochain redémarrage de l'interface. Le tic ne coûte qu'un stat tant
// qu'aucun verrou n'existe ; pendant une optimisation vivante, une lecture du
// verrou et de l'identité de son propriétaire.
func tuneRecoverWatch(tick <-chan time.Time) {
tuneRecoverStale()
for range tick {
tuneRecoverStale()
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+220
View File
@@ -0,0 +1,220 @@
package loki
// backup_bundle.go — construit et restaure un paquet de sauvegarde
// {mémoire, presets, réglages}, CHIFFRÉ côté agent avant de quitter la machine.
//
// Le relais ne voit qu'un blob opaque (voir jean-relay/backup.go). Comme le tar
// entier est chiffré, même les noms de fichiers et de presets sont cachés.
//
// Format du blob :
// magic "LOKIBK1" || uint32(len entête) || entête JSON || corps
// entête : { v, salt, kdf, box_pw, box_rec } — la DEK de sauvegarde wrappée
// par la KEK(mot de passe) ET par la KEK(clé de récupération).
// corps : gcmSeal(backupDEK, tar.gz du bundle).
//
// Restauration : mot de passe → KEK → déballe la DEK → déchiffre le corps. Ne
// dépend d'AUCUN état de l'ancienne machine : un serveur vierge suffit.
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
)
var backupMagic = []byte("LKBK1") // v2 : entête = keyvault, corps chiffré par la DEK
const backupBodyAAD = "loki-backup-body"
// buildBundleTar empaquète mémoire + presets + réglages en un tar.gz.
// Les fichiers mémoire sont pris TELS QUELS (chiffrés si le chiffrement est
// actif) : le bundle reste donc chiffré même avant l'enveloppe de sauvegarde.
func buildBundleTar() ([]byte, error) {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
addDir := func(prefix, dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return nil // dossier absent = rien à ajouter
}
for _, e := range entries {
if e.IsDir() {
continue
}
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
return err
}
hdr := &tar.Header{Name: prefix + "/" + e.Name(), Mode: 0o600, Size: int64(len(b))}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if _, err := tw.Write(b); err != nil {
return err
}
}
return nil
}
// Mémoire de TOUS les projets : un sous-dossier par projet (memory/<slug>/...).
if roots, err := os.ReadDir(projectsRoot()); err == nil {
for _, d := range roots {
if !d.IsDir() {
continue
}
if err := addDir("memory/"+d.Name(), filepath.Join(projectsRoot(), d.Name())); err != nil {
return nil, err
}
}
}
if err := addDir("presets", presetsDir()); err != nil {
return nil, err
}
// Réglages (bucket config) sérialisés en JSON.
cfg, _ := json.Marshal(ReadConfig())
if err := tw.WriteHeader(&tar.Header{Name: "config.json", Mode: 0o600, Size: int64(len(cfg))}); err != nil {
return nil, err
}
if _, err := tw.Write(cfg); err != nil {
return nil, err
}
if err := tw.Close(); err != nil {
return nil, err
}
if err := gz.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// buildBackupBlob assemble le paquet chiffré : magic || uint32(len keyvault) ||
// keyvault JSON || gcmSeal(DEK, tar). AUCUN mot de passe de sauvegarde : le corps
// est chiffré avec la DEK de la mémoire (déjà en RAM), et le keyvault (la DEK
// wrappée par la clé d'API + la clé de récupération) voyage en entête. La
// restauration n'a donc besoin que de la clé d'API. Le relais ne voit qu'un blob
// opaque : le keyvault en entête ne contient que la DEK CHIFFRÉE.
func buildBackupBlob(v *keyVault, dek, tarData []byte) ([]byte, error) {
kv, err := json.Marshal(v)
if err != nil {
return nil, err
}
body, err := gcmSeal(dek, tarData, []byte(backupBodyAAD))
if err != nil {
return nil, err
}
var out bytes.Buffer
out.Write(backupMagic)
var lenbuf [4]byte
binary.BigEndian.PutUint32(lenbuf[:], uint32(len(kv)))
out.Write(lenbuf[:])
out.Write(kv)
out.Write(body)
return out.Bytes(), nil
}
// openBackupBlob lit l'entête keyvault, l'ouvre avec secret (clé d'API OU clé de
// récupération) pour retrouver la DEK, puis déchiffre le corps.
func openBackupBlob(blob []byte, secret string) ([]byte, error) {
if !bytes.HasPrefix(blob, backupMagic) {
return nil, fmt.Errorf("format de sauvegarde non reconnu")
}
rest := blob[len(backupMagic):]
if len(rest) < 4 {
return nil, fmt.Errorf("blob tronqué")
}
n := binary.BigEndian.Uint32(rest[:4])
rest = rest[4:]
if int(n) > len(rest) {
return nil, fmt.Errorf("entête tronqué")
}
var v keyVault
if err := json.Unmarshal(rest[:n], &v); err != nil {
return nil, fmt.Errorf("entête illisible : %w", err)
}
body := rest[n:]
dek, _, err := v.unlockWith(secret)
if err != nil {
dek, _, err = v.unlockWith(normalizeRecovery(secret))
}
if err != nil {
return nil, fmt.Errorf("clé incorrecte")
}
return gcmOpen(dek, body, []byte(backupBodyAAD))
}
// restoreBundleTar réécrit mémoire + presets + réglages depuis un tar.gz. Prend
// D'ABORD un snapshot de sécurité de la mémoire, puis écrit de façon vérifiée.
func restoreBundleTar(tarData []byte) error {
if _, err := snapshotMemory("avant-restauration"); err != nil {
return fmt.Errorf("snapshot de sécurité impossible, restauration annulée : %w", err)
}
gz, err := gzip.NewReader(bytes.NewReader(tarData))
if err != nil {
return err
}
tr := tar.NewReader(gz)
var cfgJSON []byte
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
b, err := io.ReadAll(tr)
if err != nil {
return err
}
// Les chemins tar sont TOUJOURS à séparateur « / » (jamais l'OS) : on
// raisonne en slash, sinon le préfixe "memory/" casse sous Windows.
name := path.Clean("/" + strings.ReplaceAll(hdr.Name, "\\", "/"))[1:]
if strings.Contains(name, "..") {
continue // sécurité : jamais de traversée
}
base := path.Base(name)
switch {
case name == "config.json":
cfgJSON = b
case strings.HasPrefix(name, "memory/"):
// Chemin relatif SOUS memory/ (memory/<slug>/page.md). Les sauvegardes
// d'avant les projets stockaient les pages à plat (memory/page.md) : on
// les rattache au projet par défaut.
rel := strings.TrimPrefix(name, "memory/")
if !strings.Contains(rel, "/") {
rel = defaultProjectSlug + "/" + rel
}
dst := filepath.Join(projectsRoot(), filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if err := memWriteFileVerified(dst, b, 0o600); err != nil {
return err
}
case strings.HasPrefix(name, "presets/"):
if err := os.MkdirAll(presetsDir(), 0o755); err != nil {
return err
}
if err := memWriteFileVerified(filepath.Join(presetsDir(), base), b, 0o600); err != nil {
return err
}
}
}
if len(cfgJSON) > 0 {
var cfg map[string]string
if json.Unmarshal(cfgJSON, &cfg) == nil {
_ = WriteConfig(cfg)
}
}
return nil
}
+75
View File
@@ -0,0 +1,75 @@
package loki
import (
"os"
"path/filepath"
"strings"
"testing"
)
// Un paquet exporté doit se rouvrir avec la clé de pilotage comme avec la clé de
// récupération, et rendre la mémoire à l'identique. C'est le scénario « je
// remonte mon conteneur ailleurs » : rien d'autre que le fichier et la clé.
func TestBackupBundleRoundTrip(t *testing.T) {
testHome(t)
clearMemDEK()
if err := MemAdd("notes.md", "# Notes\nsecret du client\n"); err != nil {
t.Fatal(err)
}
recovery, err := EnableMemEncryption("clé-de-pilotage")
if err != nil {
t.Fatal(err)
}
v, err := loadVault()
if err != nil || v == nil {
t.Fatalf("coffre introuvable après activation: %v", err)
}
dek, err := currentDEK()
if err != nil {
t.Fatal(err)
}
tarData, err := buildBundleTar()
if err != nil {
t.Fatal(err)
}
blob, err := buildBackupBlob(v, dek, tarData)
if err != nil {
t.Fatal(err)
}
// Le paquet ne doit rien laisser voir en clair.
if strings.Contains(string(blob), "secret du client") {
t.Fatal("le contenu d'une page apparaît en clair dans le paquet")
}
// La clé de pilotage l'ouvre…
if _, err := openBackupBlob(blob, "clé-de-pilotage"); err != nil {
t.Fatalf("ouverture avec la clé de pilotage: %v", err)
}
// …la clé de récupération aussi…
back, err := openBackupBlob(blob, recovery)
if err != nil {
t.Fatalf("ouverture avec la clé de récupération: %v", err)
}
// …et une mauvaise clé, non.
if _, err := openBackupBlob(blob, "pas la bonne"); err == nil {
t.Fatal("une clé fausse aurait dû être refusée")
}
// Page effacée puis restaurée depuis le paquet : contenu intact.
p, _ := safeMemPath("notes.md")
if err := os.Remove(p); err != nil {
t.Fatal(err)
}
if err := restoreBundleTar(back); err != nil {
t.Fatal(err)
}
got, ok := memPageText("notes.md")
if !ok || !strings.Contains(got, "secret du client") {
t.Fatalf("page non restaurée: %q (lisible=%v)", got, ok)
}
// Le snapshot de sécurité pris avant restauration doit exister.
if entries, err := os.ReadDir(filepath.Join(LokiHome(), "backups", "memory")); err != nil || len(entries) == 0 {
t.Fatal("aucun snapshot pris avant la restauration")
}
}
+129
View File
@@ -0,0 +1,129 @@
package loki
// browser_grid.go — surimpose une GRILLE DE COORDONNÉES sur le screenshot envoyé
// au MODÈLE (pas sur celui sauvegardé pour l'utilisateur). Un petit modèle vise
// mal au pixel près ; avec des lignes graduées tous les 100 px et des étiquettes
// chiffrées, il lit x/y précisément et browser_click_xy tombe juste du premier
// coup (bandeaux cookies en iframe, canvas, jeux…). Sans dépendance externe : la
// police de chiffres est un bitmap 3×5 codé ici.
import (
"bytes"
"image"
"image/color"
"image/draw"
"image/png"
"strconv"
)
const gridStep = 100 // espacement des lignes, en pixels
// digits5x3 : bitmap 5 lignes × 3 colonnes pour chaque chiffre (# = pixel plein).
var digits5x3 = map[rune][5]string{
'0': {"###", "# #", "# #", "# #", "###"},
'1': {" # ", "## ", " # ", " # ", "###"},
'2': {"###", " #", "###", "# ", "###"},
'3': {"###", " #", "###", " #", "###"},
'4': {"# #", "# #", "###", " #", " #"},
'5': {"###", "# ", "###", " #", "###"},
'6': {"###", "# ", "###", "# #", "###"},
'7': {"###", " #", " #", " #", " #"},
'8': {"###", "# #", "###", "# #", "###"},
'9': {"###", "# #", "###", " #", "###"},
}
// overlayGrid décode un PNG, y dessine la grille + les graduations, et renvoie le
// PNG résultant. En cas d'erreur (image illisible), renvoie l'entrée inchangée.
func overlayGrid(raw []byte) []byte {
src, err := png.Decode(bytes.NewReader(raw))
if err != nil {
return raw
}
b := src.Bounds()
img := image.NewRGBA(b)
draw.Draw(img, b, src, b.Min, draw.Src)
line := color.RGBA{255, 0, 200, 130} // magenta translucide pour les lignes
labelBG := color.RGBA{0, 0, 0, 200} // fond sombre derrière les chiffres
labelFG := color.RGBA{255, 255, 0, 255} // chiffres jaunes (contraste sur toute page)
w, h := b.Dx(), b.Dy()
// Lignes verticales + étiquette x en haut.
for x := gridStep; x < w; x += gridStep {
for y := 0; y < h; y++ {
blend(img, b.Min.X+x, b.Min.Y+y, line)
}
drawLabel(img, b.Min.X+x+2, b.Min.Y+2, strconv.Itoa(x), labelFG, labelBG)
}
// Lignes horizontales + étiquette y à gauche.
for y := gridStep; y < h; y += gridStep {
for x := 0; x < w; x++ {
blend(img, b.Min.X+x, b.Min.Y+y, line)
}
drawLabel(img, b.Min.X+2, b.Min.Y+y+2, strconv.Itoa(y), labelFG, labelBG)
}
var out bytes.Buffer
if err := png.Encode(&out, img); err != nil {
return raw
}
return out.Bytes()
}
// blend applique une couleur semi-transparente (alpha) sur un pixel.
func blend(img *image.RGBA, x, y int, c color.RGBA) {
if !(image.Pt(x, y).In(img.Bounds())) {
return
}
a := float64(c.A) / 255
o := img.RGBAAt(x, y)
img.SetRGBA(x, y, color.RGBA{
R: uint8(float64(c.R)*a + float64(o.R)*(1-a)),
G: uint8(float64(c.G)*a + float64(o.G)*(1-a)),
B: uint8(float64(c.B)*a + float64(o.B)*(1-a)),
A: 255,
})
}
// drawLabel écrit un nombre (police 3×5, échelle ×2) avec un fond sombre pour
// rester lisible sur n'importe quel fond.
func drawLabel(img *image.RGBA, x, y int, s string, fg, bg color.RGBA) {
const sc = 2
glyphW, glyphH, gap := 3*sc, 5*sc, sc
boxW := len(s)*(glyphW+gap) + gap
boxH := glyphH + 2*gap
// Fond.
for dy := -gap; dy < boxH-gap; dy++ {
for dx := -gap; dx < boxW-gap; dx++ {
setOpaque(img, x+dx, y+dy, bg)
}
}
// Chiffres.
cx := x
for _, r := range s {
g, ok := digits5x3[r]
if !ok {
cx += glyphW + gap
continue
}
for row := 0; row < 5; row++ {
for col := 0; col < 3; col++ {
if g[row][col] != '#' {
continue
}
for iy := 0; iy < sc; iy++ {
for ix := 0; ix < sc; ix++ {
setOpaque(img, cx+col*sc+ix, y+row*sc+iy, fg)
}
}
}
}
cx += glyphW + gap
}
}
func setOpaque(img *image.RGBA, x, y int, c color.RGBA) {
if image.Pt(x, y).In(img.Bounds()) {
img.SetRGBA(x, y, color.RGBA{c.R, c.G, c.B, 255})
}
}
+13 -10
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()
@@ -73,22 +75,23 @@ func cmdChat(args []string) error {
msgs = compacted
fmt.Println(dim("[contexte compacté pour tenir dans la fenêtre]"))
}
extra, err := runChat(context.Background(), InjectSkills(msgs, caps), 0.7, caps, func(ev StreamEvent) bool {
sent, tools := prepareTurn(msgs, caps)
hist := msgs
extra, err := runChatTools(context.Background(), sent, tools, 0.7, caps, func(ev StreamEvent) bool {
switch {
case ev.Err != nil:
fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error()))
case ev.NewHistory != nil:
// Compaction survenue pendant le tour : elle REMPLACE l'historique (elle
// contient déjà le tour en cours), préfixe système injecté retiré. Sans
// ça le terminal repartirait du fil complet au tour suivant.
base := ev.NewHistory
for len(base) > 0 && base[0].Role == "system" {
base = base[1:]
}
msgs = append([]Message(nil), base...)
// contient déjà le tour en cours), préfixe système injecté retiré —
// le /sys de l'utilisateur, lui, reste (historyFromView). Sans ça le
// terminal repartirait du fil complet au tour suivant.
msgs = historyFromView(ev.NewHistory, hist)
fmt.Println(dim("\n[contexte compacté pour tenir dans la fenêtre]"))
case ev.Stats != nil:
stats = ev.Stats
case ev.Echo != nil:
echo = ev.Echo
case ev.DropReasoning:
// Le tour a « pensé sans agir » : on relance. Impossible d'effacer le
// texte déjà imprimé en terminal — on referme juste la ligne reasoning.
@@ -112,7 +115,7 @@ func cmdChat(args []string) error {
case "edit":
icon = "✏️"
verb = "édition"
case "web_search", "web_open", "web_read", "web_grep":
case "web_search", "web_images", "web_open", "web_read", "web_grep":
icon = "🌐"
verb = "web"
case "web_screenshot":
@@ -154,7 +157,7 @@ func cmdChat(args []string) error {
// answer, so next turn the model remembers it already did them instead
// of re-invoking the same skill/command from scratch.
msgs = append(msgs, extra...)
msgs = append(msgs, Message{Role: "assistant", Content: full.String()})
msgs = append(msgs, withEcho(Message{Role: "assistant", Content: full.String()}, echo))
}
}
}
File diff suppressed because it is too large. Load diff
+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")
}
}
@@ -0,0 +1,69 @@
package loki
import (
"strings"
"testing"
)
// Le compactage de secours ne doit se déclencher que sur un vrai débordement de
// contexte, pas sur n'importe quel refus du moteur.
func TestContextOverflow(t *testing.T) {
small := []Message{{Role: "user", Content: "salut"}}
yes := []string{
`{"error":{"code":400,"message":"request (70000 tokens) exceeds the available context size (65536 tokens), try increasing it","type":"exceed_context_size_error"}}`,
`{"error":{"message":"This model's maximum context length is 8192 tokens","code":"context_length_exceeded"}}`,
}
no := []string{
`{"error":{"code":500,"message":"Failed to parse tool call arguments as JSON","type":"server_error"}}`,
`{"error":{"code":503,"message":"Loading model","type":"unavailable_error"}}`,
`Unable to generate parser for this template`,
}
for _, m := range yes {
if !contextOverflow(m, small) {
t.Errorf("débordement non reconnu : %s", m)
}
}
for _, m := range no {
if contextOverflow(m, small) {
t.Errorf("faux débordement (déclencherait un compactage) : %s", m)
}
}
}
// Le dernier recours ramène sous la fenêtre même quand tout est dans un seul
// tour (AJEAN 0.17.5).
func TestShrinkToFit(t *testing.T) {
testHome(t)
big := strings.Repeat("x", 200000)
msgs := []Message{
{Role: "system", Content: "ctx"},
{Role: "user", Content: "vieux"},
{Role: "assistant", Content: "ok"},
{Role: "user", Content: "lis tout"},
{Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "a"}}},
{Role: "tool", ToolCallID: "a", Content: big},
{Role: "tool", ToolCallID: "b", Content: big},
}
out, changed := shrinkToFit(msgs, 132291)
if !changed || out[0].Role != "system" {
t.Fatalf("pas réduit : %v", changed)
}
if est := estimateTokens(out); est > int(float64(ctxWindow())*0.6) {
t.Fatalf("encore trop gros : %d", est)
}
if overflowTokens(`{"message":"request (132291 tokens) exceeds"}`) != 132291 {
t.Fatal("taille réelle non lue")
}
}
// Un résultat d'outil géant est borné pour le modèle, avec une mention qui dit
// au modèle de cibler plus précisément.
func TestCapToolResult(t *testing.T) {
if got := capToolResult("court"); got != "court" {
t.Fatalf("résultat court modifié : %q", got)
}
got := capToolResult(strings.Repeat("é", toolResultMax+500))
if r := []rune(got); len(r) > toolResultMax+200 || !strings.Contains(got, "tronqué") {
t.Fatalf("résultat géant mal borné (%d runes)", len(r))
}
}
+162 -3
View File
@@ -1,7 +1,13 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
)
@@ -20,10 +26,17 @@ func TestCompactBoundsProtectsHead(t *testing.T) {
um("q2"), am("r2"),
um("q3"), am("r3"),
}
// budget minuscule → queue = juste le dernier tour, tête = system + 1er user.
// budget minuscule → queue = juste le dernier tour, tête = les seuls systèmes.
// Le 1er message user N'EST PLUS épinglé : il l'était pour « ancrer
// l'objectif », mais après compaction le modèle voyait deux demandes (celle du
// début, épinglée, et la courante réinjectée par `pending`) et répondait
// volontiers à l'ancienne. Voir compactBounds et TestCompactKeepsPendingRequest.
head, tail := compactBounds(msgs, 1)
if head != 2 { // system + premier user
t.Fatalf("head = %d, attendu 2", head)
if head != 1 { // le message système, et lui seul
t.Fatalf("head = %d, attendu 1 (systèmes uniquement)", head)
}
if msgs[head].Role != "user" {
t.Fatalf("le 1er message user doit être compactable, il commence le torse ; rôle obtenu %q", msgs[head].Role)
}
// Frontière sûre = user ou assistant (jamais un `tool`, qui serait orphelin).
if r := msgs[tail].Role; r != "user" && r != "assistant" {
@@ -170,3 +183,149 @@ func TestEstimateTokensGrows(t *testing.T) {
t.Fatalf("estimateTokens ne croît pas avec la taille: small=%d big=%d", small, big)
}
}
// Repli sans résumé (moteur absent) : la demande en cours est déjà dans le
// torse dégraissé, elle ne doit pas être réinjectée une seconde fois — elle
// apparaissait APRÈS ses propres résultats d'outils (AJEAN 0.17.4).
func TestCompactSansResumeNeDoublePasLaDemande(t *testing.T) {
testHome(t)
page := func(n int) Message {
return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
}
msgs := []Message{
um("première question de la conversation"), am("ok"),
um("cherche les horaires du train pour Lyon"),
}
for i := 0; i < 10; i++ {
msgs = append(msgs, atc("web_read"), page(i))
}
out, _ := compactMessages(t.Context(), msgs, Caps{})
n := 0
for _, m := range out {
if m.Role == "user" && strings.Contains(msgText(m), "horaires du train") {
n++
}
}
if n != 1 {
t.Fatalf("demande en cours présente %d fois après compactage, attendu 1", n)
}
}
// Mode Code : le résumé demande les fichiers touchés et les erreurs, et l'état
// des critères est rendu au builder après compactage.
func TestCompactageModeCodeGardeLEtat(t *testing.T) {
withWorkspace(t)
toolCriteria(map[string]any{"action": "add", "texts": []any{"les tests passent"}}, false)
var sys string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Messages []Message `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
sys = msgText(body.Messages[0])
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- main.go modifié : la fonction parse corrigée, go build et go test passent"}}}})
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
page := func(n int) Message {
return tm("sortie de commande " + string(rune('a'+n)) + strings.Repeat("x", 400))
}
msgs := []Message{um("corrige le build"), am("ok"), um("continue")}
for i := 0; i < 10; i++ {
msgs = append(msgs, atc("bash"), page(i))
}
out, changed := compactMessages(t.Context(), msgs, Caps{Agent: true, Code: true})
if !changed {
t.Fatal("pas compacté")
}
if !strings.Contains(sys, "CODING session") {
t.Fatal("consigne de résumé du mode Code absente")
}
found := false
for _, m := range out {
if strings.Contains(msgText(m), "Acceptance criteria (current state)") && strings.Contains(msgText(m), "les tests passent") {
found = true
}
}
if !found {
t.Fatal("critères non rendus après compactage")
}
}
// Le résumé n'est plus coupé à 2200 caractères : max_tokens borne déjà la
// sortie, et l'ancienne coupe faisait tomber l'ÉTAT D'AVANCEMENT, écrit en
// dernier. Seul le raisonnement sans résumé est refusé (repli sur le torse).
func TestCleanSummary(t *testing.T) {
testHome(t)
// Fenêtre fixée : le filet (budget×6) en dépend, le cas « long » ne doit pas
// basculer au-delà si la valeur par défaut de CTX change.
if err := SetConfigKey("CTX", "65536"); err != nil {
t.Fatal(err)
}
long := strings.Repeat("é", 7000) + " ÉTAT D'AVANCEMENT"
huge := strings.Repeat("x", summaryRuneCap()+500)
cases := []struct {
name, content, reasoning, finish string
want string
err bool
}{
{"simple", " - fait A\n- fait B ", "", "stop", "- fait A\n- fait B", false},
{"long gardé entier", long, "", "stop", long, false},
{"think retiré", "<think>je réfléchis</think>\n- résumé", "", "stop", "- résumé", false},
{"length gardé et marqué", "- début du résumé", "", "length", "- début du résumé […]", false},
{"au-delà du filet", huge, "", "stop", strings.Repeat("x", summaryRuneCap()) + " […]", false},
{"balise citée en milieu", "- le parseur retire <think> en tête", "", "stop", "- le parseur retire <think> en tête", false},
{"bloc cité en milieu gardé entier", "- A\n- le parseur retire <think>x</think> en tête\n- B", "", "stop", "- A\n- le parseur retire <think>x</think> en tête\n- B", false},
{"think retiré, balise citée après", "<think>r</think>\n- A\n- le parseur coupe à </think>\n- B", "", "stop", "- A\n- le parseur coupe à </think>\n- B", false},
{"ouverture dans le prompt", "je réfléchis</think>\n- résumé", "", "stop", "- résumé", false},
{"deux blocs en tête", "<think>a</think><think>b</think>\n- résumé", "", "stop", "- résumé", false},
{"second bloc jamais refermé", "<think>a</think>\n<think>b", "", "length", "", true},
{"think jamais refermé", "<think>je réfléchis encore", "", "length", "", true},
{"que du reasoning_content", "", "je réfléchis", "length", "", true},
{"think vide de réponse", "<think>x</think> ", "", "stop", "", true},
{"vide", "", "", "stop", "", true},
}
for _, c := range cases {
got, err := cleanSummary(c.content, c.reasoning, c.finish)
if (err != nil) != c.err || got != c.want {
t.Errorf("%s : %q, %v", c.name, got, err)
}
}
}
// Bout à bout : un résumé de 7000 caractères revient intact du moteur, et une
// réponse qui n'a que du raisonnement est une erreur (repli de l'appelant).
func TestSummarizeLongNotTruncated(t *testing.T) {
testHome(t)
if err := SetConfigKey("CTX", "65536"); err != nil {
t.Fatal(err)
}
long := strings.Repeat("ü", 7000)
// Réponse changée entre deux requêtes : derrière un mutex, le handler tourne
// dans une goroutine du serveur.
var mu sync.Mutex
var reply map[string]any
setReply := func(r map[string]any) { mu.Lock(); reply = r; mu.Unlock() }
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
_ = json.NewEncoder(w).Encode(reply)
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
setReply(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": long}, "finish_reason": "stop"}}})
got, err := summarizeTranscriptFor(context.Background(), "transcript", false)
if err != nil || got != long {
t.Fatalf("résumé tronqué : %d caractères, %v", len([]rune(got)), err)
}
setReply(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "", "reasoning_content": "hmm"}, "finish_reason": "length"}}})
if got, err := summarizeTranscriptFor(context.Background(), "transcript", false); err == nil {
t.Fatalf("raisonnement seul accepté comme résumé : %q", got)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,155 @@
package loki
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
bolt "go.etcd.io/bbolt"
)
// resetConvForTest vide l'état en RAM pour forcer un VRAI rechargement depuis la
// base, comme au démarrage d'un process qui ne connaît encore rien. `conv` est un
// global du paquet : sans ça, un test verrait l'état laissé par le précédent.
func resetConvForTest() {
conv.mu.Lock()
conv.Messages, conv.Log, conv.Seq, conv.CtxUsed = nil, nil, 0, 0
conv.mu.Unlock()
}
// TestLoadConversationReessayeSousContention : la panne d'origine est un verrou
// bbolt transitoire au redémarrage du conteneur (l'ancien process tient encore
// le fichier pendant que le nouveau démarre), confondu avec « rien à charger ».
//
// Réserve honnête : ce test ne reproduit PAS la panne à coup sûr. withDB ouvre
// déjà la base avec Options.Timeout de 5 s, qui absorbe seul une contention de
// 400 ms — ce test passe donc aussi sur le code d'AVANT le correctif. Il est
// gardé comme filet de non-régression sur le nouveau chemin (getBytesErr + boucle
// de tentatives) : ce chemin ne doit ni perdre la conversation, ni se bloquer.
func TestLoadConversationReessayeSousContention(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
// 1. Écrit une conversation par l'API normale (elle crée aussi la clé active).
resetConvForTest()
conv.mu.Lock()
conv.Messages = []Message{{Role: "user", Content: "bonjour"}}
conv.mu.Unlock()
conv.persist()
activeID := getStr(bkChat, ckActive)
if activeID == "" {
t.Fatal("persist n'a pas créé de discussion active — le montage du test est faux")
}
// 2. État en RAM vidé : le rechargement doit tout reprendre depuis la base.
resetConvForTest()
// 3. Verrou exclusif tenu une fenêtre courte dans une goroutine séparée : la
// base est momentanément indisponible, exactement comme au chevauchement.
db, err := bolt.Open(dbPath(), 0o600, &bolt.Options{Timeout: 2 * time.Second})
if err != nil {
t.Fatalf("impossible de prendre le verrou pour le test : %v", err)
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(400 * time.Millisecond)
db.Close()
}()
LoadConversation()
wg.Wait()
conv.mu.Lock()
got := len(conv.Messages)
conv.mu.Unlock()
if got != 1 {
t.Fatalf("conversation perdue malgré une contention temporaire : %d message(s), attendu 1", got)
}
if id := getStr(bkChat, ckActive); id != activeID {
t.Fatalf("la discussion active a changé (%q → %q) : le fil d'origine est orphelin", activeID, id)
}
}
// TestLoadConversationBaseIllisibleNeForgePasDeDiscussion garde l'invariant qui
// justifie le correctif : convEnsureActive ÉCRIT (elle forge un identifiant et
// l'enregistre sous bkChat/active). Elle ne doit donc jamais être appelée sur la
// foi d'une lecture ratée — sinon un verrou durable suffisait à mettre le fil en
// cours de côté au profit d'une discussion neuve, en silence.
//
// Réserve honnête, là encore : avec une base illisible l'écriture échoue elle
// aussi, donc le disque finit identique dans les deux versions. Ce que ce test
// verrouille, c'est le CONTRAT — sortir sans rien écrire, et sans faire attendre
// indéfiniment — pas la reproduction du dégât.
func TestLoadConversationBaseIllisibleNeForgePasDeDiscussion(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
// Fichier de base invalide : bolt.Open échoue tout de suite (pas d'attente de
// verrou), ce qui exerce le chemin d'erreur sans allonger le test.
path := filepath.Join(home, "loki.db")
if err := os.WriteFile(path, []byte("ceci n'est pas une base bbolt"), 0o600); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
resetConvForTest()
start := time.Now()
LoadConversation()
elapsed := time.Since(start)
// Budget : loadConvAttempts tentatives espacées de loadConvRetryWait, plus une
// marge. Au-delà, la boucle de reprise retarderait le démarrage du serveur.
if max := time.Duration(loadConvAttempts)*loadConvRetryWait + 2*time.Second; elapsed > max {
t.Fatalf("démarrage retardé de %v par une base illisible (budget %v)", elapsed, max)
}
// Contrepartie de l'assertion « aucune reprise » du test d'absence légitime :
// un compteur bloqué à zéro la rendrait vraie sans rien prouver. Ici les
// reprises ont bel et bien lieu, et on le vérifie.
if n := loadConvRetries.Load(); int(n) != loadConvAttempts {
t.Fatalf("%d reprise(s) comptée(s) sur une base illisible, attendu %d", n, loadConvAttempts)
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(before) != string(after) {
t.Fatal("la base a été modifiée alors qu'elle n'a même pas pu être lue")
}
conv.mu.Lock()
n := len(conv.Messages)
conv.mu.Unlock()
if n != 0 {
t.Fatalf("%d message(s) chargés depuis une base illisible", n)
}
}
// TestLoadConversationAbsenceLegitimeResteRapide : le correctif ne doit pas avoir
// transformé le cas NORMAL (base saine, rien d'enregistré encore — premier
// démarrage) en attente inutile. Une seule tentative doit suffire.
func TestLoadConversationAbsenceLegitimeResteRapide(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
resetConvForTest()
LoadConversation()
// Mesuré sur le COMPTEUR de reprises, pas au chronomètre : une absence
// légitime doit sortir au premier essai. Le temps de mur ne disait pas ça —
// il mêlait le coût de l'essai lui-même (création de la base bbolt, fsync)
// à l'attente de reprise, et un runner CI chargé l'a fait échouer à 435 ms
// alors qu'aucune reprise n'avait été déclenchée.
if n := loadConvRetries.Load(); n != 0 {
t.Fatalf("absence légitime traitée comme une erreur : %d reprise(s) déclenchée(s), aucune ne devait l'être", n)
}
conv.mu.Lock()
n := len(conv.Messages)
conv.mu.Unlock()
if n != 0 {
t.Fatalf("%d message(s) chargés alors que rien n'avait été enregistré", n)
}
}
+84
View File
@@ -225,3 +225,87 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) {
c.appendDelta(c.epoch, map[string]any{"content": "suite"})
waitSeq(t, got, 503)
}
// --- Régressions v0.12.7 : journal d'affichage (outils, replay, changement de session) ---
// compactLogLocked doit coalescer les événements tool_used d'un même appel en un
// seul (le done=true, qui porte l'état final) : les intermédiaires (annonce, frappe
// du corps) ne servent qu'à l'affichage en direct et faisaient enfler le journal
// jusqu'à la troncature des premiers messages.
func TestCompactLogCoalesceTools(t *testing.T) {
c := newTestConv()
c.Log = []LogEvent{
{Seq: 1, Delta: map[string]any{"user": "édite"}},
{Seq: 2, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f"}}},
{Seq: 3, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f", "typing": true, "body": "a"}}},
{Seq: 4, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f", "result": "ok", "done": true}}},
{Seq: 5, Delta: map[string]any{"content": "fini"}},
}
c.mu.Lock()
c.compactLogLocked()
c.mu.Unlock()
nTool := 0
for _, ev := range c.Log {
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
nTool++
if done, _ := tu["done"].(bool); !done {
t.Error("un tool_used non terminé a survécu à la compaction")
}
}
}
if nTool != 1 {
t.Fatalf("compactLogLocked garde %d tool_used au lieu de 1", nTool)
}
}
// coalesceReplay ne doit émettre un outil qu'UNE fois même si un événement non-outil
// (stats, un bout de raisonnement) tombe entre l'annonce (done=false) et le done=true.
func TestCoalesceReplayNoDoubleTool(t *testing.T) {
events := []LogEvent{
{Seq: 1, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls"}}},
{Seq: 2, Delta: map[string]any{"stats": map[string]any{"gen_tokens": 3}}},
{Seq: 3, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls", "result": "x", "done": true}}},
}
n := 0
for _, ev := range coalesceReplay(events, 0) {
if _, ok := ev["tool_used"]; ok {
n++
}
}
if n != 1 {
t.Fatalf("outil émis %d fois au replay (attendu 1)", n)
}
}
// Un abonné qui arrive avec un `from` SUPÉRIEUR au dernier Seq du journal (curseur
// hérité d'une autre session, plus récente) doit obtenir le fil COMPLET, pas rien :
// sans ce garde-fou, ouvrir une session plus ancienne la montrait tronquée/vide.
func TestSubscribeStaleFromCrossSession(t *testing.T) {
c := newTestConv()
c.appendDelta(c.epoch, map[string]any{"user": "premier message"})
c.appendDelta(c.epoch, map[string]any{"content": "réponse"})
// Le journal va jusqu'à Seq 2 ; on s'abonne avec from=9999 (curseur d'une autre session).
ctx, cancel := context.WithCancel(context.Background())
var got []map[string]any
done := make(chan struct{})
go func() {
c.Subscribe(ctx, 9999, func(ev map[string]any) bool {
got = append(got, ev)
if ev["caught_up"] != nil {
cancel()
}
return true
})
close(done)
}()
<-done
seen := ""
for _, ev := range got {
if u, ok := ev["user"].(string); ok {
seen += u
}
}
if !strings.Contains(seen, "premier message") {
t.Fatalf("le premier message n'a pas été rejoué avec un from périmé (reçu: %q)", seen)
}
}
+304
View File
@@ -0,0 +1,304 @@
package loki
// chat_convfiles.go — les fichiers rangés PAR DISCUSSION.
//
// Avant, tout ce qui passait par le chat — pièces jointes déposées, rapports
// écrits par l'agent, captures de pages — atterrissait dans UN seul dossier de
// travail partagé par toutes les discussions. Changer de discussion ne changeait
// rien au panneau Fichiers (on revoyait les mêmes), et supprimer une discussion
// laissait ses fichiers derrière elle — seules ses captures, déjà rangées par
// identifiant, partaient avec.
//
// Désormais chaque discussion a son dossier :
//
// <workspace>/discussions/<id>/ ← dossier courant de l'agent
// <workspace>/discussions/<id>/uploads/ ← pièces jointes de CETTE discussion
// <workspace>/discussions/<id>/captures/ ← captures de CETTE discussion
//
// Supprimer une discussion (ou la vider) emporte tout le dossier, et le panneau
// Fichiers n'ouvre que celui de la discussion active.
//
// La racine du dossier de travail reste la BORNE de sécurité : les liens écrits
// dans les anciens messages (`uploads/x.pdf`, `captures/<id>/y.jpg`) pointent
// encore vers elle et doivent continuer de marcher — c'est le rôle de
// workspaceFile, qui cherche d'abord dans la discussion puis retombe sur les
// emplacements d'avant.
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"unicode"
)
const (
// convFilesRoot : le sous-dossier qui contient un dossier par discussion.
// Nommé, plutôt que d'éparpiller les identifiants à la racine, pour que le
// dossier de travail reste lisible quand on l'ouvre depuis l'hôte.
convFilesRoot = "discussions"
// uploadsSub : les dépôts de l'utilisateur, à l'intérieur de la discussion.
uploadsSub = "uploads"
)
// safeConvID refuse tout identifiant qui ne soit pas un simple nom : c'est lui
// qui devient un segment de chemin, et il arrive parfois du client (paramètre de
// requête, ancien lien de message). Renvoie "" si l'identifiant est douteux.
func safeConvID(id string) string {
id = strings.TrimSpace(id)
if id == "" || len(id) > 64 {
return ""
}
for _, r := range id {
if r == '-' || r == '_' || unicode.IsDigit(r) || (r < unicode.MaxASCII && unicode.IsLetter(r)) {
continue
}
return ""
}
return id
}
// convDirFor renvoie le dossier (absolu) d'une discussion, sans le créer. Un
// identifiant vide ou douteux retombe sur la racine du dossier de travail :
// mieux vaut écrire au mauvais endroit que refuser d'écrire.
func convDirFor(convID string) string {
id := safeConvID(convID)
if id == "" {
return agentWorkspace()
}
return filepath.Join(agentWorkspace(), convFilesRoot, id)
}
// convWorkspace est le dossier de travail COURANT : celui de la discussion
// active, créé au besoin. C'est là que résolvent les chemins relatifs du modèle
// (resolveAgentPath), que démarre son shell, et que se déposent les fichiers.
//
// Si la création échoue (disque plein, droits), on retombe sur la racine du
// dossier de travail : l'agent doit continuer d'écrire quelque part.
func convWorkspace() string {
dir := convDirFor(convEnsureActive())
if dir == agentWorkspace() {
return dir
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return agentWorkspace()
}
return dir
}
// dropConvFiles efface TOUT le dossier d'une discussion (dépôts, captures,
// fichiers écrits par l'agent). Appelé quand la discussion est supprimée ou
// vidée : plus un message ne les mentionne, personne ne les retrouverait.
// Best-effort — un échec ne doit rien interrompre.
func dropConvFiles(convID string) {
dir := convDirFor(convID)
if dir == agentWorkspace() {
return // identifiant douteux : surtout pas la racine
}
_ = os.RemoveAll(dir)
}
// relWithin dit si `abs` se trouve DANS `root` et, si oui, renvoie son chemin
// relatif en séparateurs '/'.
//
// EvalSymlinks des deux côtés : sans ça, un lien posé dans le dossier passerait
// le test de préfixe tout en pointant ailleurs sur le disque.
func relWithin(root, abs string) (string, bool) {
if root == "" {
return "", false
}
if r, err := filepath.EvalSymlinks(root); err == nil {
root = r
}
if a, err := filepath.EvalSymlinks(abs); err == nil {
abs = a
}
rel, err := filepath.Rel(root, abs)
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", false
}
return filepath.ToSlash(rel), true
}
// workspaceFile résout un chemin RELATIF venu d'un client ou d'un message
// (`uploads/rapport.pdf`, `captures/xxx.jpg`) en fichier du disque.
//
// Ordre de recherche :
// 1. le dossier de la discussion active — le cas normal ;
// 2. les emplacements d'AVANT le rangement par discussion, pour que les liens
// des anciens messages continuent d'ouvrir leur fichier.
//
// ok=false signale une sortie du périmètre (« .. »), pas une absence : un
// fichier introuvable revient avec le chemin attendu, pour que l'appelant
// réponde « introuvable » et non « hors du dossier de travail ».
func workspaceFile(rel string) (string, bool) {
rel = strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(rel), "\\", "/"), "/")
if rel == "" {
return "", false
}
base := convWorkspace()
p := filepath.Join(base, filepath.FromSlash(rel))
if _, ok := relWithin(base, p); !ok {
return "", false
}
if _, err := os.Stat(p); err == nil {
return p, true
}
if legacy, ok := legacyWorkspaceFile(rel); ok {
return legacy, true
}
return p, true
}
// legacyWorkspaceFile retrouve un fichier désigné par un chemin d'avant le
// rangement par discussion. Deux formes existaient :
//
// captures/<id>/x.jpg → discussions/<id>/captures/x.jpg (déplacé par la migration)
// uploads/x.pdf → resté à la racine si aucune discussion ne le réclamait
func legacyWorkspaceFile(rel string) (string, bool) {
if parts := strings.SplitN(rel, "/", 3); len(parts) == 3 && parts[0] == captureDir && safeConvID(parts[1]) != "" {
p := filepath.Join(convDirFor(parts[1]), captureDir, filepath.FromSlash(parts[2]))
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p, true
}
}
p := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
if _, ok := workspaceRel(p); !ok {
return "", false
}
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p, true
}
return "", false
}
// filesRoot donne la racine du panneau Fichiers pour un `scope` de requête :
// la discussion active par défaut, la racine du dossier de travail pour
// `scope=legacy` — celle où dorment les fichiers d'avant le rangement par
// discussion, que la migration n'a pas su rattacher.
func filesRoot(scope string) (root string, legacy bool) {
if strings.TrimSpace(scope) == "legacy" {
return agentWorkspace(), true
}
return convWorkspace(), false
}
// legacyCount compte ce qui traîne à la racine du dossier de travail, hors
// dossier des discussions. L'UI n'affiche le raccourci « hors discussion » que
// si ce compte est non nul — il tombe à zéro une fois le ménage fait, et le
// bouton disparaît de lui-même.
func legacyCount() int {
ents, err := os.ReadDir(agentWorkspace())
if err != nil {
return 0
}
n := 0
for _, e := range ents {
if e.Name() == convFilesRoot || strings.HasPrefix(e.Name(), ".") {
continue
}
n++
}
return n
}
var convFilesMigrateOnce sync.Once
// migrateConvFiles range les fichiers des versions précédentes dans le dossier
// de leur discussion. Une seule fois par process, au démarrage du serveur web.
func migrateConvFiles() { convFilesMigrateOnce.Do(migrateConvFilesNow) }
func migrateConvFilesNow() {
root := agentWorkspace()
if root == "" {
return
}
// 1. Les captures étaient DÉJÀ par discussion : captures/<id> → discussions/<id>/captures.
// Un simple renommage, donc instantané quel que soit le nombre d'images.
oldCaps := filepath.Join(root, captureDir)
if ents, err := os.ReadDir(oldCaps); err == nil {
for _, e := range ents {
id := safeConvID(e.Name())
if !e.IsDir() || id == "" {
continue
}
dst := filepath.Join(convDirFor(id), captureDir)
if _, err := os.Stat(dst); err == nil {
continue // déjà rangé (migration interrompue puis reprise)
}
if os.MkdirAll(filepath.Dir(dst), 0o755) != nil {
continue
}
_ = os.Rename(filepath.Join(oldCaps, e.Name()), dst)
}
_ = os.Remove(oldCaps) // ne part que s'il est vide
}
// 2. Les dépôts, eux, étaient dans un pot commun. On rend chaque fichier à la
// discussion qui le mentionne — l'historique garde le nom des pièces
// jointes de chaque tour. Ce qui n'est réclamé par personne reste à la
// racine : toujours téléchargeable (legacyWorkspaceFile) et visible via
// « hors discussion », plutôt que rattaché au hasard.
migrateLegacyUploads(filepath.Join(root, uploadsSub))
}
func migrateLegacyUploads(src string) {
ents, err := os.ReadDir(src)
if err != nil {
return
}
left := map[string]bool{}
for _, e := range ents {
if !e.IsDir() {
left[e.Name()] = true
}
}
for _, m := range convIndex() {
if len(left) == 0 {
break
}
for _, name := range convAttachNames(m.ID) {
if !left[name] {
continue
}
dst := filepath.Join(convDirFor(m.ID), uploadsSub)
if os.MkdirAll(dst, 0o755) != nil {
continue
}
if os.Rename(filepath.Join(src, name), filepath.Join(dst, name)) == nil {
delete(left, name)
}
}
}
_ = os.Remove(src) // ne part que s'il est vide
}
// convAttachNames relit les pièces jointes annoncées dans le journal d'affichage
// d'une discussion (delta `files`, cf. StartTurn). C'est la seule trace qui
// relie un fichier déposé à sa discussion.
func convAttachNames(id string) []string {
b := storeBytes(bkChat, convKey(id))
if len(b) == 0 {
return nil
}
var st struct {
Log []struct {
Delta struct {
Files []attachInfo `json:"files"`
} `json:"delta"`
} `json:"log"`
}
if json.Unmarshal(b, &st) != nil {
return nil
}
var out []string
for _, ev := range st.Log {
for _, f := range ev.Delta.Files {
if n := safeUploadName(f.Name); n != "" {
out = append(out, n)
}
}
}
return out
}
+243
View File
@@ -0,0 +1,243 @@
package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// Le cœur de la demande : deux discussions ne partagent pas leurs fichiers.
// Avant, uploads/ était commun — on retrouvait les mêmes pièces jointes partout.
func TestFichiersIsolesParDiscussion(t *testing.T) {
withWorkspace(t)
a := convEnsureActive()
dirA, err := uploadsDir()
if err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dirA, "note-a.txt"), "aaa")
b := convNew() // b devient active
if a == b {
t.Fatal("convNew n'a pas créé de nouvelle discussion")
}
dirB, err := uploadsDir()
if err != nil {
t.Fatal(err)
}
if dirA == dirB {
t.Fatalf("les deux discussions déposent au même endroit : %s", dirA)
}
writeFile(t, filepath.Join(dirB, "note-b.txt"), "bbb")
// La discussion ouverte ne voit QUE ses propres dépôts.
names := listedNames(t, "uploads")
if names["note-a.txt"] || !names["note-b.txt"] {
t.Fatalf("la liste de la discussion b montre %v", names)
}
// Et le message ne peut pas joindre le fichier de l'autre discussion.
if got := attachFiles([]string{"uploads/note-a.txt"}); len(got) != 0 {
t.Fatalf("pièce jointe d'une autre discussion retenue : %+v", got)
}
if got := attachFiles([]string{"uploads/note-b.txt"}); len(got) != 1 {
t.Fatalf("pièce jointe de la discussion courante refusée : %+v", got)
}
// Retour sur a : ses fichiers sont là, ceux de b ont disparu de la vue.
if err := convSwitch(a); err != nil {
t.Fatal(err)
}
names = listedNames(t, "uploads")
if !names["note-a.txt"] || names["note-b.txt"] {
t.Fatalf("la liste de la discussion a montre %v", names)
}
}
// Supprimer une discussion emporte TOUS ses fichiers, pas seulement ses
// captures — et surtout pas ceux des autres.
func TestConvDeleteSupprimeLesFichiers(t *testing.T) {
ws := withWorkspace(t)
a := convEnsureActive()
writeFile(t, filepath.Join(ws, "uploads", "joint.pdf"), "pdf")
writeFile(t, filepath.Join(ws, "rapport.md"), "rapport")
b := convNew()
autre := convWorkspace()
writeFile(t, filepath.Join(autre, "garde-moi.txt"), "ok")
if err := convDelete(a); err != nil {
t.Fatalf("suppression : %v", err)
}
if _, err := os.Stat(ws); !os.IsNotExist(err) {
t.Fatalf("le dossier de la discussion supprimée est toujours là (%v)", err)
}
if _, err := os.Stat(filepath.Join(autre, "garde-moi.txt")); err != nil {
t.Fatalf("les fichiers d'une AUTRE discussion ont été supprimés : %v", err)
}
if convEnsureActive() != b {
t.Fatalf("discussion active = %q, attendu %q", convEnsureActive(), b)
}
}
// Vider une discussion (« clear chat ») efface aussi ses fichiers : les messages
// qui les mentionnaient partent avec.
func TestResetSupprimeLesFichiersDeLaDiscussion(t *testing.T) {
ws := withWorkspace(t)
writeFile(t, filepath.Join(ws, "uploads", "joint.pdf"), "pdf")
conv.Reset()
if _, err := os.Stat(filepath.Join(ws, "uploads", "joint.pdf")); !os.IsNotExist(err) {
t.Fatalf("le fichier de la discussion vidée a survécu (%v)", err)
}
}
// Un identifiant de discussion douteux ne doit JAMAIS devenir un segment de
// chemin : dropConvFiles effacerait alors la racine du dossier de travail.
func TestDropConvFilesRefuseUnIdDouteux(t *testing.T) {
ws := withWorkspace(t)
writeFile(t, filepath.Join(ws, "a.txt"), "a")
racine := agentWorkspace()
for _, bad := range []string{"", ".", "..", "../..", "c1/../..", "/etc", `..\..`} {
dropConvFiles(bad)
if _, err := os.Stat(racine); err != nil {
t.Fatalf("id %q a détruit le dossier de travail : %v", bad, err)
}
}
if _, err := os.Stat(filepath.Join(ws, "a.txt")); err != nil {
t.Fatalf("fichier de la discussion détruit : %v", err)
}
}
// Les liens des anciens messages doivent continuer d'ouvrir leur fichier : ils
// pointent vers uploads/ et captures/<id>/ à la RACINE du dossier de travail.
func TestTelechargementDesCheminsDAvant(t *testing.T) {
withWorkspace(t)
racine := agentWorkspace()
id := convEnsureActive()
writeFile(t, filepath.Join(racine, "uploads", "ancien.txt"), "avant")
// Capture d'avant, déplacée par la migration là où elle est désormais rangée.
writeFile(t, filepath.Join(convDirFor(id), captureDir, "vue.jpg"), "jpeg")
for path, want := range map[string]string{
"uploads/ancien.txt": "avant",
captureDir + "/" + id + "/vue.jpg": "jpeg",
captureDir + "/vue.jpg": "jpeg", // le chemin d'aujourd'hui
} {
rec := httptest.NewRecorder()
handleChatFile(rec, httptest.NewRequest("GET", "/api/chat/file?path="+url.QueryEscape(path), nil))
if rec.Code != 200 || rec.Body.String() != want {
t.Errorf("path=%q : code %d, corps %q", path, rec.Code, rec.Body.String())
}
}
}
// La migration range les fichiers des versions précédentes : les captures par
// leur identifiant, les dépôts par la discussion qui les mentionne. Ce que
// personne ne réclame reste à la racine, atteignable par « hors discussion ».
func TestMigrationRangeLesFichiersDAvant(t *testing.T) {
withWorkspace(t)
racine := agentWorkspace()
a := convEnsureActive()
// Un tour avec pièce jointe : c'est cette trace, dans le journal de la
// discussion, qui rattache le fichier à elle.
state, _ := json.Marshal(map[string]any{
"log": []map[string]any{{"seq": 1, "delta": map[string]any{
"user": "regarde",
"files": []attachInfo{{Name: "joint.pdf", Path: "uploads/joint.pdf", Size: 3}},
}}},
})
if err := putBytes(bkChat, convKey(a), state); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(racine, "uploads", "joint.pdf"), "pdf")
writeFile(t, filepath.Join(racine, "uploads", "orphelin.bin"), "??")
writeFile(t, filepath.Join(racine, captureDir, a, "vue.jpg"), "jpeg")
// La migration ne joue qu'une fois par process : on rearme le verrou pour
// pouvoir l'observer ici.
convFilesMigrateOnce = sync.Once{}
migrateConvFiles()
if _, err := os.Stat(filepath.Join(convDirFor(a), uploadsSub, "joint.pdf")); err != nil {
t.Errorf("le dépôt n'a pas rejoint sa discussion : %v", err)
}
if _, err := os.Stat(filepath.Join(convDirFor(a), captureDir, "vue.jpg")); err != nil {
t.Errorf("la capture n'a pas rejoint sa discussion : %v", err)
}
// Le fichier que personne ne réclame reste lisible plutôt que rattaché au hasard.
if _, err := os.Stat(filepath.Join(racine, uploadsSub, "orphelin.bin")); err != nil {
t.Errorf("le dépôt orphelin a été perdu : %v", err)
}
if legacyCount() == 0 {
t.Error("le reliquat hors discussion n'est pas signalé à l'UI")
}
}
// La vue « hors discussion » ne doit pas devenir une porte dérobée vers les
// fichiers des autres discussions.
func TestScopeLegacyNeDonnePasLesDiscussions(t *testing.T) {
ws := withWorkspace(t)
racine := agentWorkspace()
writeFile(t, filepath.Join(ws, "secret.txt"), "x")
writeFile(t, filepath.Join(racine, "ancien.txt"), "y")
out := listScope(t, "", "legacy")
if out["ok"] != true {
t.Fatalf("liste refusée : %v", out)
}
for _, e := range out["entries"].([]any) {
if e.(map[string]any)["name"] == convFilesRoot {
t.Fatalf("le dossier des discussions est listé : %v", out)
}
}
// Et il ne se supprime pas d'un clic : ce serait toutes les discussions.
rec := httptest.NewRecorder()
handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete",
strings.NewReader(`{"path":`+jsonQuote(convFilesRoot)+`,"scope":"legacy"}`)))
if rec.Code == http.StatusOK {
t.Errorf("suppression du dossier des discussions acceptée : %s", rec.Body.String())
}
if _, err := os.Stat(filepath.Join(ws, "secret.txt")); err != nil {
t.Fatalf("fichier d'une discussion supprimé : %v", err)
}
}
// listedNames renvoie les noms listés dans un dossier du panneau.
func listedNames(t *testing.T, dir string) map[string]bool {
t.Helper()
out := listFiles(t, dir)
names := map[string]bool{}
if out["ok"] != true {
return names
}
for _, e := range out["entries"].([]any) {
names[e.(map[string]any)["name"].(string)] = true
}
return names
}
func listScope(t *testing.T, dir, scope string) map[string]any {
t.Helper()
rec := httptest.NewRecorder()
handleChatFiles(rec, httptest.NewRequest("GET",
"/api/chat/files?dir="+url.QueryEscape(dir)+"&scope="+url.QueryEscape(scope), nil))
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("réponse illisible (%d) : %s", rec.Code, rec.Body.String())
}
out["_code"] = rec.Code
return out
}
+83 -24
View File
@@ -5,12 +5,18 @@ package loki
// envoie à l'UI le détail des lignes ajoutées et retirées, qu'elle affiche en
// vert (+) et rouge (-). Aucune dépendance : LCS classique sur les lignes, avec
// des garde-fous pour ne jamais transformer un gros remplacement en pavé.
//
// Les lignes envoyées sont bornées (diffMaxShown), mais les compteurs +N / -N
// sont calculés AVANT la coupe et renvoyés à part : l'UI les comptait dans le
// diff tronqué, d'où un « +500 » pendant l'écriture qui retombait à « +120 ».
import "strings"
const (
diffMaxLines = 400 // au-delà, on compare sans détail (trop gros / trop lent)
diffMaxShown = 120 // lignes envoyées à l'UI (le reste est résumé)
diffMaxLines = 400 // au-delà (partie modifiée), on compare sans détail (LCS trop lente)
diffMaxShown = 120 // lignes envoyées à l'UI (le reste est résumé)
diffContext = 3 // lignes identiques gardées autour d'un changement
diffOmitPrefix = "…"
)
// DiffLine est une ligne de diff : Op vaut " " (contexte), "-" ou "+".
@@ -19,26 +25,69 @@ type DiffLine struct {
Text string `json:"text"`
}
// lineDiff compare deux blocs de texte ligne à ligne. Le résultat garde les
// lignes communes comme contexte : c'est ce qui rend le changement lisible
// quand l'IA ne touche qu'un mot au milieu d'un paragraphe.
func lineDiff(oldText, newText string) []DiffLine {
// lineDiff compare deux blocs de texte ligne à ligne et renvoie les lignes à
// afficher (bornées) avec le nombre RÉEL de lignes ajoutées et retirées.
//
// Les lignes communes en tête et en queue sont écartées avant la LCS : un
// changement d'une ligne dans un gros bloc reste un +1 -1 lisible (au lieu d'un
// « tout retiré, tout ajouté ») et le changement n'est pas repoussé hors des
// lignes affichées par des centaines de lignes de contexte.
func lineDiff(oldText, newText string) (lines []DiffLine, added, removed int) {
a := splitLines(oldText)
b := splitLines(newText)
// Blocs énormes : on ne calcule pas la LCS (coût quadratique), on montre
// simplement l'ancien en retrait et le nouveau en ajout.
if len(a) > diffMaxLines || len(b) > diffMaxLines {
out := make([]DiffLine, 0, len(a)+len(b))
for _, l := range a {
out = append(out, DiffLine{Op: "-", Text: l})
pre := 0
for pre < len(a) && pre < len(b) && a[pre] == b[pre] {
pre++
}
suf := 0
for suf < len(a)-pre && suf < len(b)-pre && a[len(a)-1-suf] == b[len(b)-1-suf] {
suf++
}
ma, mb := a[pre:len(a)-suf], b[pre:len(b)-suf]
var mid []DiffLine
if len(ma) > diffMaxLines || len(mb) > diffMaxLines {
// Partie modifiée énorme : pas de LCS (coût quadratique), l'ancien en
// retrait puis le nouveau en ajout.
mid = make([]DiffLine, 0, len(ma)+len(mb))
for _, l := range ma {
mid = append(mid, DiffLine{Op: "-", Text: l})
}
for _, l := range b {
out = append(out, DiffLine{Op: "+", Text: l})
for _, l := range mb {
mid = append(mid, DiffLine{Op: "+", Text: l})
}
} else {
mid = lcsDiff(ma, mb)
}
for _, d := range mid {
switch d.Op {
case "+":
added++
case "-":
removed++
}
return capLines(out)
}
// LCS : table des longueurs, puis remontée.
// Contexte autour du changement : quelques lignes, le reste résumé.
var out []DiffLine
if pre > diffContext {
out = append(out, DiffLine{Op: " ", Text: omitted(pre - diffContext)})
}
for _, l := range a[max(0, pre-diffContext):pre] {
out = append(out, DiffLine{Op: " ", Text: l})
}
out = append(out, mid...)
for _, l := range a[len(a)-suf : len(a)-suf+min(suf, diffContext)] {
out = append(out, DiffLine{Op: " ", Text: l})
}
if suf > diffContext {
out = append(out, DiffLine{Op: " ", Text: omitted(suf - diffContext)})
}
return capLines(out), added, removed
}
// lcsDiff : diff exact par plus longue sous-séquence commune.
func lcsDiff(a, b []string) []DiffLine {
n, m := len(a), len(b)
lcs := make([][]int, n+1)
for i := range lcs {
@@ -77,27 +126,37 @@ func lineDiff(oldText, newText string) []DiffLine {
for ; j < m; j++ {
out = append(out, DiffLine{Op: "+", Text: b[j]})
}
return capLines(out)
return out
}
// addedDiff présente un contenu entièrement nouveau (création d'une page).
func addedDiff(text string) []DiffLine {
lines := splitLines(text)
out := make([]DiffLine, 0, len(lines))
for _, l := range lines {
// addedDiff présente un contenu entièrement nouveau (création d'une page ou
// d'un fichier) : toutes ses lignes en ajout.
func addedDiff(text string) (lines []DiffLine, added int) {
ls := splitLines(text)
out := make([]DiffLine, 0, len(ls))
for _, l := range ls {
out = append(out, DiffLine{Op: "+", Text: l})
}
return capLines(out)
return capLines(out), len(ls)
}
func splitLines(s string) []string {
s = strings.ReplaceAll(s, "\r\n", "\n")
// Un saut de ligne final termine la dernière ligne, il n'en ouvre pas une vide.
s = strings.TrimSuffix(s, "\n")
if s == "" {
return nil
}
return strings.Split(s, "\n")
}
func omitted(n int) string {
if n == 1 {
return diffOmitPrefix + "(1 ligne identique)"
}
return diffOmitPrefix + "(" + itoa(n) + " lignes identiques)"
}
// capLines borne la taille envoyée à l'UI et signale ce qui a été coupé.
func capLines(d []DiffLine) []DiffLine {
if len(d) <= diffMaxShown {
@@ -105,7 +164,7 @@ func capLines(d []DiffLine) []DiffLine {
}
cut := len(d) - diffMaxShown
out := append([]DiffLine{}, d[:diffMaxShown]...)
return append(out, DiffLine{Op: " ", Text: "…(" + itoa(cut) + " lignes de plus)"})
return append(out, DiffLine{Op: " ", Text: diffOmitPrefix + "(" + itoa(cut) + " lignes de plus)"})
}
func itoa(n int) string {
+68
View File
@@ -0,0 +1,68 @@
package loki
import (
"strings"
"testing"
)
func numbered(n int, suffix string) string {
var b strings.Builder
for i := 1; i <= n; i++ {
b.WriteString("ligne " + itoa(i) + suffix + "\n")
}
return b.String()
}
// Un fichier de 500 lignes : +500, même si l'UI n'en reçoit que 120.
func TestAddedDiffCountsBeyondCap(t *testing.T) {
lines, added := addedDiff(numbered(500, ""))
if added != 500 {
t.Fatalf("added = %d, attendu 500", added)
}
if len(lines) > diffMaxShown+1 {
t.Fatalf("%d lignes envoyées, plafond %d", len(lines), diffMaxShown)
}
}
// Le saut de ligne final ne compte pas pour une ligne.
func TestAddedDiffTrailingNewline(t *testing.T) {
if _, n := addedDiff("a\nb\n"); n != 2 {
t.Fatalf("n = %d, attendu 2", n)
}
if _, n := addedDiff("a\nb"); n != 2 {
t.Fatalf("n = %d, attendu 2", n)
}
}
// Une ligne changée au milieu d'un gros bloc : +1 -1, et le changement est
// bien dans les lignes affichées (pas noyé sous le contexte).
func TestLineDiffSmallChangeInBigBlock(t *testing.T) {
old := numbered(800, "")
neu := strings.Replace(old, "ligne 600\n", "ligne 600 modifiée\n", 1)
lines, add, del := lineDiff(old, neu)
if add != 1 || del != 1 {
t.Fatalf("+%d -%d, attendu +1 -1", add, del)
}
var sawAdd, sawDel bool
for _, l := range lines {
sawAdd = sawAdd || (l.Op == "+" && l.Text == "ligne 600 modifiée")
sawDel = sawDel || (l.Op == "-" && l.Text == "ligne 600")
}
if !sawAdd || !sawDel {
t.Fatalf("le changement n'est pas dans le diff affiché : %v", lines)
}
if len(lines) > 2*diffContext+4 {
t.Fatalf("trop de contexte : %d lignes", len(lines))
}
}
func TestLineDiffCountsAreExact(t *testing.T) {
_, add, del := lineDiff("a\nb\nc\n", "a\nx\ny\nc\n")
if add != 2 || del != 1 {
t.Fatalf("+%d -%d, attendu +2 -1", add, del)
}
_, add, del = lineDiff(numbered(300, ""), numbered(300, " bis"))
if add != 300 || del != 300 {
t.Fatalf("+%d -%d, attendu +300 -300", add, del)
}
}
+116
View File
@@ -0,0 +1,116 @@
package loki
// chat_envctx.go — PROJ_SNAPSHOT : la date et le dossier de travail sortent du
// prompt système pour entrer dans le bloc projet figé de la discussion.
//
// Pourquoi. Le système (préambule + briefing machine) et les outils forment le
// début de chaque requête ; tant qu'ils ne bougent pas, le moteur reprend son
// cache. Deux valeurs y bougeaient pourtant : la date — à minuit, tout le prompt
// d'une discussion en cours est recalculé — et le dossier de travail, propre à
// chaque discussion — deux discussions ne partageaient donc rien, pas même le
// système. Avec la clé, le système ne garde que des consignes (« ton dossier de
// travail est dans ton contexte ») et les valeurs partent dans le bloc projet,
// en tête du premier message utilisateur, avec le reste du contexte figé.
//
// Rien n'est mémorisé au-delà du bloc lui-même : chaque tour relit la date, le
// dossier et la cible, et tout écart part en <context_update> dans le message
// suivant — une ligne pour un changement de jour, le bloc entier sinon (poste
// distant passé hors ligne, autre cible, autre dossier). Les mêmes faits, à une
// autre place ; aucun n'est retenu par Loki plus longtemps qu'avant.
//
// Seul un tour de discussion avec la clé y passe (assembleTurn pose
// caps.envInCtx). Une tâche planifiée, un sous-agent, la vérification du mode
// Code, le terminal gardent leur système d'avant, date et dossier compris.
import (
"strings"
"time"
)
// envContextPrefix ouvre le message (isProjectSystem le range avec le contexte
// du projet). Assez particulier pour qu'aucun prompt de preset ne commence ainsi.
const envContextPrefix = "Environment (from Loki)"
// nodeOfflineLine : l'avertissement d'un poste distant hors ligne — dans le
// système sans la clé, dans le bloc projet avec.
const nodeOfflineLine = "⚠ It is currently OFFLINE: those tools will fail until it reconnects. Tell the user instead of trying repeatedly."
// envNow : l'horloge du bloc (remplaçable en test).
var envNow = time.Now
// envDateLine : la date du jour, avec l'année et l'année périmée que la
// consigne web du système désignait jusque-là par leur valeur.
func envDateLine(day string) string {
year := day
if len(day) >= 4 {
year = day[:4]
}
return "Date: " + day + " (the current year is " + year + ", not " + prevYear(year) + ")."
}
// envMachineFacts : les valeurs du briefing machine qui varient — dossier de la
// discussion, ou poste distant (nom, système, dossier, état). "" sans agent.
// Les consignes qui les accompagnent restent dans machineSystemPrompt.
func envMachineFacts(caps Caps) string {
if !caps.Agent {
return ""
}
if tgt, ok := nodeTargetMetaGet(); ok {
s := "Machine: REMOTE node " + tgt.name
if tgt.os != "" {
s += " (" + tgt.os + ")"
}
if tgt.root != "" {
s += ", working folder " + tgt.root
}
s += "."
if !tgt.connected {
s += " " + nodeOfflineLine
}
return s
}
if cwd := agentCwd(); cwd != "" {
return "Working folder (cwd): " + cwd + "."
}
return ""
}
// envCapture : la date (si le préambule en portait une) et les valeurs machine
// de ce tour.
func envCapture(caps Caps) (day, facts string) {
if caps.Agent || caps.Mem != MemOff {
day = envNow().Format("2006-01-02")
}
return day, envMachineFacts(caps)
}
// envContextMessage : le message du bloc, ok=false s'il n'a rien à dire.
func envContextMessage(day, facts string) (Message, bool) {
var lines []string
if day != "" {
lines = append(lines, envDateLine(day))
}
if facts != "" {
lines = append(lines, facts)
}
if len(lines) == 0 {
return Message{}, false
}
return Message{Role: "system", Content: envContextPrefix + ":\n" + strings.Join(lines, "\n")}, true
}
// envDelta : la mise à jour entre l'environnement annoncé et le vivant. Un
// changement de jour seul tient en une ligne ; tout autre écart renvoie le
// message entier.
func envDelta(oldDay, oldFacts, day, facts string) string {
switch {
case oldFacts == facts && oldDay == day:
return ""
case oldFacts == facts && day != "" && oldDay != "":
return "New day — " + envDateLine(day) + "\n"
}
if m, ok := envContextMessage(day, facts); ok {
return "Current version, replaces any earlier one — " + msgText(m) + "\n"
}
return "Environment: removed.\n"
}
+185
View File
@@ -0,0 +1,185 @@
package loki
import (
"encoding/json"
"os"
"os/user"
"runtime"
"strings"
"testing"
"time"
)
// sysPlaceholders remplace dans un prompt système les valeurs propres à la
// machine et au jour (date, année, hôte, utilisateur, dossiers) par des jetons
// fixes : ce qui reste doit être identique, octet pour octet, d'une machine à
// l'autre.
func sysPlaceholders(s string) string {
now := time.Now()
year := now.Format("2006")
host, _ := os.Hostname()
if host == "" {
host = "unknown"
}
who := ""
if u, err := user.Current(); err == nil {
who = u.Username
}
pairs := []string{
now.Format("2006-01-02"), "<DATE>",
scriptsDir(), "<SCRIPTS>",
agentCwd(), "<CWD>",
"host=" + host, "host=<HOST>",
runtime.GOOS + "/" + runtime.GOARCH, "<OS>/<ARCH>",
"The shell is " + agentTargetShellName(), "The shell is <SHELL>",
}
if who != "" {
pairs = append(pairs, "user="+who, "user=<USER>")
}
pairs = append(pairs, year, "<YEAR>", prevYear(year), "<PREVYEAR>")
for i := 0; i < len(pairs); i += 2 {
s = strings.ReplaceAll(s, pairs[i], pairs[i+1])
}
return s
}
func sysOf(t *testing.T, msgs []Message) string {
t.Helper()
if len(msgs) == 0 || msgs[0].Role != "system" {
t.Fatalf("pas de système en tête : %+v", msgs)
}
s, _ := msgs[0].Content.(string)
return s
}
// Gabarits du système SANS PROJ_SNAPSHOT, relevés sur le code d'avant le
// déplacement de la date et du dossier de travail : ils ne doivent pas bouger
// d'un octet (valeurs du jour et de la machine remplacées par des jetons).
var sysGolden = map[string]string{
"agent": "You are Loki, an expert assistant operating directly on this machine with real tools. You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.\n\nThe shell is <SHELL>: use its syntax.\n\nManaging your memory is part of the job, not optional:\n- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nDate: <DATE>\n\nMachine: host=<HOST>, <OS>/<ARCH>, user=<USER>, cwd=<CWD>. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder <SCRIPTS> instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
"internet": "You are Loki, an expert assistant operating directly on this machine with real tools. You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.\n\nThe shell is <SHELL>: use its syntax.\n\nManaging your memory is part of the job, not optional:\n- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nWeb: web_open first, then web_read/web_grep on it.\nYour training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, 'since when') call web_search BEFORE writing any date or version, and match what you actually read.\nToday is in <YEAR>. If a query needs a year use ONLY <YEAR>, never a remembered past year like <PREVYEAR> — it biases results toward stale pages; better still, omit the year. Don't hedge ('probably') about a fact a tool can verify — search instead.\n\nDate: <DATE>\n\nMachine: host=<HOST>, <OS>/<ARCH>, user=<USER>, cwd=<CWD>. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder <SCRIPTS> instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
"ondemand": "You are Loki, an expert assistant operating directly on this machine with real tools.\n\nThe shell is <SHELL>: use its syntax.\n\nMemory is ON-DEMAND: you have the mem_* tools but do NOT read or write memory on your own. Call mem_search/mem_read only when the user explicitly asks you to recall or look something up, and mem_add/mem_edit only when the user explicitly asks you to remember something. Otherwise leave memory untouched and answer directly.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nDate: <DATE>\n\nMachine: host=<HOST>, <OS>/<ARCH>, user=<USER>, cwd=<CWD>. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder <SCRIPTS> instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
"memseule": "You are Loki, an expert assistant operating directly on this machine with real tools. You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.\n\n\nManaging your memory is part of the job, not optional:\n- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\n\nDate: <DATE>",
}
func TestSystemeSansCleInchange(t *testing.T) {
projSnapSetup(t, false)
cases := map[string]Caps{
"agent": {Agent: true, Mem: MemAlways},
"internet": {Agent: true, Internet: true, Mem: MemAlways},
"ondemand": {Agent: true, Mem: MemOnDemand},
"memseule": {Mem: MemAlways},
}
for name, caps := range cases {
got, _ := prepareTurn([]Message{um("salut")}, caps)
s := sysPlaceholders(sysOf(t, got))
if want, ok := sysGolden[name]; !ok || s != want {
t.Errorf("%s : système changé sans la clé\nreçu %q\nattendu %q", name, s, want)
}
}
}
// toolsOf : le champ tools d'une requête, tel qu'envoyé.
func toolsOf(t *testing.T, body string) string {
t.Helper()
var p struct {
Tools json.RawMessage `json:"tools"`
}
if err := json.Unmarshal([]byte(body), &p); err != nil {
t.Fatal(err)
}
return string(p.Tools)
}
// Avec la clé : deux discussions dans deux dossiers ont le MÊME système et les
// mêmes outils ; la date et le dossier sont dans le bloc projet de chacune.
func TestEnvCtxSystemeCommunAuxDiscussions(t *testing.T) {
m := projSnapSetup(t, true)
t.Cleanup(func() { setTaskWorkspace("") })
caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
var bodies []string
dirs := []string{t.TempDir(), t.TempDir()}
for _, d := range dirs {
setTaskWorkspace(d)
bodies = append(bodies, playTurn(t, m, newTestConv(), caps, "salut"))
}
s0, s1 := systemContent(t, bodies[0]), systemContent(t, bodies[1])
if s0 != s1 || toolsOf(t, bodies[0]) != toolsOf(t, bodies[1]) {
t.Fatalf("système ou outils différents d'une discussion à l'autre :\n%q\n%q", s0, s1)
}
today := time.Now().Format("2006-01-02")
year := today[:4]
for _, v := range []string{today, year, dirs[0], dirs[1], "cwd="} {
if strings.Contains(s0, v) {
t.Fatalf("%q encore dans le système : %q", v, s0)
}
}
if !strings.Contains(s0, "Your working folder (cwd) is given in your context") || !strings.Contains(s0, scriptsDir()) {
t.Fatalf("consignes du dossier de travail perdues : %q", s0)
}
for i, b := range bodies {
u := userContents(t, b)[0]
if !strings.Contains(u, envContextPrefix+":\n"+envDateLine(today)) || !strings.Contains(u, "Working folder (cwd): "+dirs[i]+".") {
t.Fatalf("discussion %d : date ou dossier absents du bloc : %q", i, u)
}
}
}
// Changement de jour : une ligne de mise à jour, une seule fois ; système et
// bloc figé inchangés, donc tout le début du prompt reste en cache.
func TestEnvCtxChangementDeJour(t *testing.T) {
m := projSnapSetup(t, true)
day := time.Date(2026, 12, 31, 23, 50, 0, 0, time.Local)
envNow = func() time.Time { return day }
t.Cleanup(func() { envNow = time.Now })
c := newTestConv()
caps := Caps{Agent: true, Mem: MemAlways}
b1 := playTurn(t, m, c, caps, "salut")
day = day.Add(20 * time.Minute)
b2 := playTurn(t, m, c, caps, "et maintenant ?")
b3 := playTurn(t, m, c, caps, "merci")
if systemContent(t, b1) != systemContent(t, b2) || systemContent(t, b2) != systemContent(t, b3) {
t.Fatal("le système a changé avec le jour")
}
u1, u2, u3 := userContents(t, b1), userContents(t, b2), userContents(t, b3)
if u1[0] != u2[0] || u2[0] != u3[0] || !strings.Contains(u1[0], "Date: 2026-12-31 (the current year is 2026, not 2025).") {
t.Fatalf("bloc figé modifié ou sans date : %q / %q", u1[0], u2[0])
}
want := ctxUpdOpen
last := u2[len(u2)-1]
if !strings.HasPrefix(last, want) || !strings.Contains(last, "New day — Date: 2027-01-01 (the current year is 2027, not 2026).") {
t.Fatalf("mise à jour du jour attendue : %q", last)
}
if u3[1] != last || strings.Contains(u3[len(u3)-1], ctxUpdOpen) {
t.Fatalf("la mise à jour doit partir une fois, puis rester telle quelle : %q", u3)
}
}
// Tâches, sous-agents, vérification, terminal : la clé ne change rien à leur
// système, qui garde la date et le dossier.
func TestEnvCtxHorsDiscussionInchange(t *testing.T) {
projSnapSetup(t, true)
caps := Caps{Agent: true, Mem: MemAlways}
got, _ := prepareTurn([]Message{um("tâche")}, caps)
s := sysOf(t, got)
if !strings.Contains(s, "\nDate: "+time.Now().Format("2006-01-02")) || !strings.Contains(s, ", cwd="+agentCwd()+".") {
t.Fatalf("système d'une tâche modifié par la clé : %q", s)
}
if mp := machineSystemPrompt(caps); !strings.Contains(mp, "cwd="+agentCwd()) {
t.Fatalf("briefing d'un sous-agent sans dossier : %q", mp)
}
}
// Poste distant passé hors ligne, autre dossier : le bloc entier repart, rien
// n'est retenu d'avant.
func TestEnvDelta(t *testing.T) {
if d := envDelta("2026-10-04", "x", "2026-10-04", "x"); d != "" {
t.Fatalf("rien de changé : %q", d)
}
if d := envDelta("2026-10-04", "Working folder (cwd): /a.", "2026-10-04", "Machine: REMOTE node pc. "+nodeOfflineLine); !strings.Contains(d, "Current version") || !strings.Contains(d, nodeOfflineLine) || !strings.Contains(d, "Date: 2026-10-04") {
t.Fatalf("bloc entier attendu : %q", d)
}
if d := envDelta("2026-10-04", "x", "2026-10-05", "x"); d != "New day — "+envDateLine("2026-10-05")+"\n" {
t.Fatalf("une ligne attendue : %q", d)
}
}
+72 -3
View File
@@ -103,12 +103,80 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
Version: Version,
ExportedAt: time.Now().Format(time.RFC3339),
CtxUsed: ctxUsed,
Messages: msgs,
Log: filterLog(log, o),
// Sans les mises à jour du projet (PROJ_SNAPSHOT) : l'export rend la
// conversation, pas les données que Loki y a glissées.
Messages: stripInlineImages(stripContextUpdates(msgs)),
Log: filterLog(log, o),
}
return json.MarshalIndent(p, "", " ")
}
// stripInlineImages remplace les images en base64 (data URI) portées par les
// messages multimodaux (vision : pièces jointes images + outil see_image) par un
// court marqueur. Sans ça, l'export JSON d'une conversation avec images embarquait
// plusieurs Mo de base64 par image — un fichier énorme et illisible. Le Markdown,
// lui, n'était pas concerné : il ne cite que les NOMS de fichiers. Le journal
// d'affichage non plus (il ne porte pas les images). On ne touche donc qu'ici, et
// sur une COPIE : les messages appartiennent à la conversation vivante.
func stripInlineImages(msgs []Message) []Message {
out := make([]Message, len(msgs))
for i, m := range msgs {
out[i] = m
out[i].Content = stripInlineImagesValue(m.Content)
}
return out
}
func stripInlineImagesValue(v any) any {
// Une partie image (format OpenAI) : {"type":"image_url","image_url":{"url":"data:…"}}.
// On copie la partie et on élide l'URL data: en gardant l'en-tête (type MIME) lisible.
elide := func(part map[string]any) map[string]any {
cp := make(map[string]any, len(part))
for k, val := range part {
cp[k] = val
}
if iu, ok := cp["image_url"].(map[string]any); ok {
c2 := make(map[string]any, len(iu))
for k, val := range iu {
c2[k] = val
}
if url, ok := c2["url"].(string); ok && strings.HasPrefix(url, "data:") {
c2["url"] = elideDataURI(url)
}
cp["image_url"] = c2
}
return cp
}
switch parts := v.(type) {
case []map[string]any: // message fraîchement produit
out := make([]map[string]any, len(parts))
for i, p := range parts {
out[i] = elide(p)
}
return out
case []any: // message relu depuis la base (JSON) : []any de maps
out := make([]any, len(parts))
for i, p := range parts {
if mp, ok := p.(map[string]any); ok {
out[i] = elide(mp)
} else {
out[i] = p
}
}
return out
}
return v // simple chaîne (cas courant) : rien à faire
}
// elideDataURI garde l'en-tête « data:<mime>;base64, » (lisible) et remplace le
// corps base64 par sa taille, pour que l'export reste informatif sans le poids.
func elideDataURI(url string) string {
if i := strings.Index(url, ","); i >= 0 {
return url[:i+1] + fmt.Sprintf("<élidé — %s>", humanBytes(int64(len(url)-i-1)))
}
return "<image élidée>"
}
// filterLog applique au journal les MÊMES cases que le Markdown. Les deux
// formats avaient auparavant des options distinctes (le JSON proposait un
// obscur « journal d'affichage » à la place des trois autres) : personne ne
@@ -155,7 +223,8 @@ func filterLog(log []LogEvent, o exportOpts) []LogEvent {
func lastTurnsMessages(msgs []Message, n int) []Message {
seen := 0
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role != "user" {
// Un rappel de loki n'ouvre pas un échange.
if msgs[i].Role != "user" || isLokiInjected(msgs[i]) {
continue
}
seen++
Loaded 100 of 363 files, more files were not shown because too many files have changed in this diff. Show more