Compare commits

..
39 Commits
Author SHA1 Message Date
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
96 changed files with 14462 additions and 670 deletions

No files matched your search

+21
View File
@@ -167,6 +167,27 @@ Sur Unraid, garde le **même** chemin hôte d'un lancement à l'autre : `/mnt/us
emplacements différents dès que le partage n'est pas en cache-only ou que le
*mover* est passé. Le compose fourni utilise `/mnt/user/appdata/loki/…`.
**Performance sur Unraid.** `/mnt/user/…` passe par `shfs`, la couche FUSE des
partages : chaque écriture de `loki.db` et chaque page d'un gros modèle relue
depuis le disque (MoE plus gros que la RAM, mmappé) traverse un démon en espace
utilisateur, ce qui ralentit le décodage. Loki le détecte au démarrage
(`fuse.shfs` dans `/proc/self/mountinfo`) et l'affiche en encart d'information —
rien n'est perdu ni altéré. Pour l'éviter :
- **recommandé** — partages en *Exclusive access* (Unraid 6.12+). Ce n'est
pas une case mais un état : *Global Share Settings* → *Permit exclusive
shares* = Yes, puis le partage `appdata` (et celui des modèles) entièrement
sur **un** pool — mover d'abord vers le pool, ensuite *Secondary storage* =
None — jusqu'à lire *Exclusive access : Yes* sur la page du partage.
Redémarre le conteneur : le chemin `/mnt/user/…` ne change pas, FUSE est
court-circuité ;
- **avancé** — mappe `/mnt/<ton-pool>/appdata/loki/…` (ex. `/mnt/cache` si ton
pool s'appelle `cache`), avec une migration manuelle : `Compose Down`,
`rsync -a` de l'ancien dossier vers le nouveau, puis modification des deux
lignes `volumes`. Un `/mnt/<pool>` inexistant atterrit dans la RAM d'Unraid
(données perdues au redémarrage, serveur saturé par un modèle de 80 Go), et
changer le chemin sans migrer donne un `/data` vide.
**Tu perds modèles, discussions et fichiers à chaque redémarrage ?** C'est le
signe que `/data` n'est **pas monté** : le conteneur écrit alors dans sa couche
éphémère, détruite à chaque recréation (mise à jour d'image, `compose down`,
+25
View File
@@ -34,7 +34,32 @@ services:
# lancement). Ex. LOKI_MODEL=Qwen3-14B-Q4_K_M.gguf après dépôt du
# fichier dans /mnt/user/appdata/loki/models.
- LOKI_MODEL=
# Accès par NOM DE DOMAINE (reverse proxy, ex. loki.mondomaine.fr) sans
# clé de pilotage : liste ici le(s) nom(s), séparés par des virgules —
# sinon l'API répond 403 (protection contre les sites tiers / DNS
# rebinding). Inutile pour un accès par IP ou nom local.
- LOKI_TRUSTED_HOSTS=
volumes:
# PERFORMANCE — /mnt/user/… passe par la couche FUSE des partages
# Unraid (shfs) : chaque écriture de la base et chaque page d'un gros
# modèle relue depuis le disque (modèle plus gros que la RAM) traverse un
# démon en espace utilisateur. Loki le signale au démarrage (encart
# d'information, rien n'est perdu). Deux remèdes, au choix :
# - Recommandé (Unraid 6.12+) : partages en « Exclusive access ».
# Ce n'est pas une case mais un état : Settings -> Global Share
# Settings -> « Permit exclusive shares » = Yes, puis le partage
# appdata (et celui des modèles) entièrement sur UN pool — mover
# d'abord vers le pool, ensuite Secondary storage = None. La page du
# partage affiche alors « Exclusive access : Yes ». Redémarre le
# conteneur : le chemin /mnt/user reste le même, FUSE est
# court-circuité.
# - Avancé : mapper /mnt/<ton-pool>/appdata/loki/… (ex. /mnt/cache si
# ton pool s'appelle « cache »). Migration MANUELLE obligatoire :
# Compose Down, rsync -a de l'ancien dossier vers le nouveau, puis
# changer les deux lignes ci-dessous. Un /mnt/<pool> qui n'existe
# pas atterrit dans la RAM d'Unraid : données perdues au redémarrage,
# et un modèle de 80 Go peut saturer la mémoire du serveur.
# Ne change JAMAIS ce chemin sans migrer : /data apparaîtrait vide.
# /data : config, base, mémoire, workspace, modèles téléchargés par l'UI
- /mnt/user/appdata/loki/data:/data
# /models : dossier de .gguf déposés à la main (gros fichiers)
+3
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
+10 -1
View File
@@ -26,6 +26,7 @@ package loki
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
@@ -281,11 +282,19 @@ func parseEngineBuild(out string) (build int, commit string) {
// engineBuildOf interroge un llama-server pour son numéro de build. Renvoie
// (0, "") si le binaire ne répond pas — un moteur cassé ne doit pas empêcher
// d'en installer un autre.
//
// Borné dans le temps comme binHelp : « loki serve » le lance avant le moteur
// (points de reprise d'un hybride, voir ckptArgs), et un binaire qui reste
// coincé à l'initialisation de ses backends ne doit pas bloquer le lancement —
// passé le délai, build inconnu, donc ni avis ni drapeau.
func engineBuildOf(bin string) (int, string) {
if bin == "" || !isFile(bin) {
return 0, ""
}
cmd := hideCmd(exec.Command(bin, "--version"))
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := hideCmd(exec.CommandContext(ctx, bin, "--version"))
cmd.WaitDelay = 2 * time.Second // un descendant qui garde la sortie ouverte ne retient pas Wait
cmd.Env = libraryPathEnv(filepath.Dir(bin))
out, _ := cmd.CombinedOutput() // --version sort parfois en code non nul
return parseEngineBuild(string(out))
+1 -1
View File
@@ -230,7 +230,7 @@ func TestPresetExterneVisionEtBench(t *testing.T) {
if !visionEnabled() || !engineSeesImages() {
t.Error("preset externe déclaré multimodal : la vision devrait être active")
}
if _, err := runBench(10, 10); err == nil {
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" {
+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")
}
}
+4 -3
View File
@@ -46,10 +46,11 @@ func modelRefKey(v string) string {
}
// modelKeysOf liste les .gguf référencés par une configuration : le modèle, le
// projecteur vision, et un --mmproj posé à la main dans EXTRA_ARGS (les presets
// d'avant la clé MMPROJ l'écrivaient là).
// 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"]}
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) {
@@ -142,6 +142,14 @@ func TestProjecteurCompteCommeReference(t *testing.T) {
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 à
+6 -2
View File
@@ -189,7 +189,10 @@ 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.
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST", "CUDA_LAUNCH_QUEUES"}
// 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 +202,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}
// applyPresetFile installe le preset comme configuration active, en réinjectant
// les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être
+2 -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)
+292 -90
View File
@@ -8,12 +8,13 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"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
@@ -23,9 +24,11 @@ import (
// L'aide se lit avec le même chemin de bibliothèques que le vrai lancement
// (setLibraryPath a déjà été appelé) : sans ça un moteur parfaitement valide
// échoue à s'exécuter (« libllama-common.so introuvable ») et on conclurait à
// tort qu'il ne gère pas le drapeau.
func binSupportsReasoningFlag(bin string) bool {
return strings.Contains(binHelp(bin), "--reasoning ")
// tort qu'il ne gère pas le drapeau. Les tests de capacité prennent le TEXTE de
// l'aide, pas le binaire : buildServeArgs reste ainsi une fonction pure,
// testable avec une aide fabriquée.
func helpSupportsReasoningFlag(help string) bool {
return strings.Contains(help, "--reasoning ")
}
// binHelp lit « <bin> --help » UNE fois par binaire et garde le texte. Plusieurs
@@ -57,20 +60,19 @@ func binHelp(bin string) string {
return h
}
// binSupportsLoadMode : les moteurs récents ont fusionné --mlock, --mmap et
// helpSupportsLoadMode : les moteurs récents ont fusionné --mlock, --mmap et
// --no-mmap dans un seul --load-mode ; les anciens ne connaissent que les trois
// drapeaux d'origine. On traduit donc à l'exécution, sans réécrire le preset :
// le même EXTRA_ARGS reste lançable sur les deux générations de moteur.
func binSupportsLoadMode(bin string) bool {
return strings.Contains(binHelp(bin), "--load-mode")
func helpSupportsLoadMode(help string) bool {
return strings.Contains(help, "--load-mode")
}
// binSupportsNGLAuto : « -ngl auto » laisse llama.cpp mesurer la VRAM libre et
// helpSupportsNGLAuto : « -ngl auto » laisse llama.cpp mesurer la VRAM libre et
// choisir le nombre de couches. Imposer un nombre (999 compris) désarme ce
// calcul — « n_gpu_layers already set by user to 999, abort » — et le moteur
// tente alors de tout mettre sur le GPU, quitte à échouer en cudaMalloc.
func binSupportsNGLAuto(bin string) bool {
h := binHelp(bin)
func helpSupportsNGLAuto(h string) bool {
i := strings.Index(h, "--n-gpu-layers")
if i < 0 {
i = strings.Index(h, "-ngl")
@@ -85,16 +87,16 @@ func binSupportsNGLAuto(bin string) bool {
return strings.Contains(h[i:end], "'auto'")
}
// binFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
// helpFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
//
// La lecture fine de l'aide (binSupportsNGLAuto) reste la source de vérité, mais
// La lecture fine de l'aide (helpSupportsNGLAuto) reste la source de vérité, mais
// elle dépend de la mise en page d'un texte d'aide — une description reformulée
// ou une colonne plus large, et on conclut « non » sur un moteur parfaitement
// capable, donc on lui réimpose 999 et l'abandon revient. --load-mode est arrivé
// dans la même vague que « -ngl auto » : sa présence sert de second témoin, plus
// grossier mais insensible à la mise en page.
func binFitsLayersItself(bin string) bool {
return binSupportsNGLAuto(bin) || binSupportsLoadMode(bin)
func helpFitsLayersItself(help string) bool {
return helpSupportsNGLAuto(help) || helpSupportsLoadMode(help)
}
// nglArgs traduit la valeur NGL du preset en arguments, et renvoie au passage la
@@ -164,7 +166,10 @@ func hasAnyFlag(args []string, flags ...string) bool {
//
// none → --no-mmap · mlock → --mlock --no-mmap · mmap+mlock → --mlock
// auto, mmap, dio → rien (défaut de l'ancien moteur)
func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
//
// 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)
}
@@ -184,7 +189,7 @@ func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
}
}
if explicit || !(mlock || nommap || mmap) {
return out
return out, ""
}
mode := "mmap"
switch {
@@ -195,12 +200,12 @@ func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
case nommap:
mode = "none"
}
return append(out, "--load-mode", mode)
return append(out, "--load-mode", mode), ""
}
// downgradeLoadMode retraduit un --load-mode pour un moteur qui ne le connaît
// pas : le lui passer tel quel le ferait sortir en erreur au démarrage.
func downgradeLoadMode(args []string) []string {
func downgradeLoadMode(args []string) ([]string, string) {
mode, found := "", false
kept := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
@@ -220,7 +225,7 @@ func downgradeLoadMode(args []string) []string {
kept = append(kept, a)
}
if !found {
return args
return args, ""
}
add := func(flag string) {
for _, a := range kept {
@@ -239,14 +244,19 @@ func downgradeLoadMode(args []string) []string {
case "mmap+mlock":
add("--mlock")
case "dio":
fmt.Fprintf(os.Stderr, "[loki serve] ce moteur ne connaît pas --load-mode dio (DirectIO) : chargement par défaut\n")
return kept, "ce moteur ne connaît pas --load-mode dio (DirectIO) : chargement par défaut"
}
return kept
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()
bin := cfg["BIN"]
@@ -291,40 +301,237 @@ func cmdServe(args []string) error {
// Linux, PATH on Windows — handled inside execServer.
setLibraryPath(filepath.Dir(bin))
// Sélection GPU (loki gpu) : on filtre les devices visibles par llama-server.
// CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index correspondent à ceux
// affichés par nvidia-smi (sinon CUDA réordonne par "device le plus rapide").
if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" {
_ = os.Setenv("CUDA_VISIBLE_DEVICES", v)
_ = os.Setenv("CUDA_DEVICE_ORDER", "PCI_BUS_ID")
// La sélection GPU est posée AVANT de lire l'aide du moteur, comme elle l'a
// toujours été : le moteur interrogé voit déjà les mêmes devices que celui
// qu'on lancera. buildServeArgs la renvoie aussi dans env (même valeur).
applyServeEnv(cudaDeviceEnv(cfg))
si := serveSysInfo{Help: binHelp(bin), Model: model}
// Vision : le projecteur multimodal (mmproj-*.gguf) donne des yeux au modèle.
// C'est un fichier .gguf À PART du modèle, chargé via --mmproj. On le résout
// comme le modèle (nom simple cherché dans les dossiers déclarés, ou chemin
// absolu), pour qu'un preset écrit sous Windows reste lançable ailleurs et que
// le champ « Vision » de l'interface n'ait qu'à écrire le nom du fichier.
// Introuvable = on préfère le dire clairement plutôt que laisser llama-server
// mourir en boucle sur un « failed to load mmproj » cryptique.
if mm := strings.TrimSpace(cfg["MMPROJ"]); mm != "" {
mmPath, err := resolveServeModelPath(mm)
if err != nil {
return fmt.Errorf("projecteur vision introuvable : %s (%v)", mm, err)
}
if _, err := os.Stat(mmPath); err != nil {
return fmt.Errorf("projecteur vision introuvable : %s", mmPath)
}
si.MMProj = mmPath
}
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
si.APIKey, _ = effectiveAPIKeyErr()
// Conteneur à l'étroit (cpuset, quota) : seul Linux l'expose, dans /proc et
// /sys. Un LLAMA_ARG_THREADS déjà posé est un choix de l'utilisateur — un -t
// de Loki l'écraserait, donc on ne sonde même pas.
if runtime.GOOS == "linux" && os.Getenv("LLAMA_ARG_THREADS") == "" {
si.CPU = cpusetThreads(os.DirFS("/"))
}
extra := splitArgs(cfg["EXTRA_ARGS"])
// Deux GPU ou plus : de quoi décider des files de lancement CUDA (voir
// launchQueuesEnv). Après la sélection GPU, qui fait partie de la réponse.
probeServeGPUs(cfg, extra, &si)
probeExpertEnv(&si)
probeFidelityEnv(&si)
probeCacheRAM(cfg, extra, &si)
probeCkptEnv(&si)
spec := specMode(cfg)
if spec == "auto" || spec == "mtp" {
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)
}
if strings.Contains(si.Help, "--slot-save-path") && !hasAnyFlag(extra, "--slot-save-path") {
si.SlotDir = prepareSlotDir(LokiHome())
}
llmArgs, env, notes := buildServeArgs(cfg, extra, bin, si)
applyServeEnv(env)
for _, n := range notes {
fmt.Fprintln(os.Stderr, "[loki serve] "+n)
}
// 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" {
_, _, auto := specArgs(cfg, extra, si)
specAutoSettle(specMark, si.SpecAutoBlocked, specRecord, auto)
}
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
bin, filepath.Base(model), port)
// Hand off to the llama-server process. On Unix this replaces the current
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
return execServer(bin, llmArgs)
}
// serveSysInfo rassemble ce que cmdServe a dû demander au monde avant de
// composer la ligne de commande : l'aide du moteur (d'où se déduisent ses
// capacités), les chemins déjà résolus et vérifiés, la clé d'API. Avec elle,
// buildServeArgs n'a plus besoin de toucher ni au disque ni au binaire.
type serveSysInfo struct {
Help string // sortie de « <bin> --help » ; vide = moteur inconnu, aucun risque pris
Model string // chemin du .gguf principal, résolu et vérifié
MMProj string // chemin du projecteur vision, résolu et vérifié ; vide = pas de vision
APIKey string // clé effective (.api_key, sinon config.env) ; vide = serveur ouvert
CPU cpuBudget // sonde de conteneur (Linux) ; zéro = llama.cpp choisit ses threads seul
LaunchQueues string // CUDA_SCALE_LAUNCH_QUEUES déjà dans l'environnement : choix de l'utilisateur, intouché
GPUs int // GPU visibles (CUDA_VISIBLE_DEVICES, sinon nvidia-smi) ; 0 = inconnu ou non sondé
ArgEnv map[string]string // LLAMA_ARG_* de l'environnement qui décident du pipeline, de --fit et du cache (serveArgEnv, fitArgEnv, fidelityArgEnv)
UserEnv map[string]string // GGML_* des réglages d'expert déjà posés (voir expertEnvKeys) : intouchés
// Cache de prompts (voir backend_serve_cache.go). Zéro = inconnu : le moteur
// garde son défaut.
GGUF *GGUFInfo // métadonnées du modèle ; nil = illisibles
ModelBytes int64 // taille du modèle, toutes tranches
RAMMiB int64 // RAM de la machine, limite du conteneur comprise
RAMAvailMiB int64 // RAM libre au lancement
VRAMMiB int64 // VRAM NVIDIA des cartes visibles
SlotDir string // dossier de --slot-save-path, créé et vérifié ; vide = pas de drapeau
// Build d'un moteur officiel (engineBuildTrusted), lu seulement pour un
// modèle hybride ou SPEC=auto ; 0 = inconnu ou non lu : ni avis ni drapeau
// automatique.
EngineBuild int
// Décodage spéculatif (voir backend_serve_spec.go), lu seulement si SPEC le
// demande.
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
}
// cudaDeviceEnv : sélection GPU (loki gpu), on filtre les devices visibles par
// llama-server. CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index
// correspondent à ceux affichés par nvidia-smi (sinon CUDA réordonne par
// « device le plus rapide »).
func cudaDeviceEnv(cfg map[string]string) map[string]string {
env := map[string]string{}
if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" {
env["CUDA_VISIBLE_DEVICES"] = v
env["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID"
}
return env
}
func applyServeEnv(env map[string]string) {
for k, v := range env {
_ = os.Setenv(k, v)
}
}
// serveKVTypes donne les types de cache K et V du preset : KV_TYPE pour les
// deux, KV_TYPE_K / KV_TYPE_V pour les régler séparément. Vide = défaut moteur.
func serveKVTypes(cfg map[string]string) (k, v string) {
kv := cfg["KV_TYPE"]
k, v = kv, kv
if s := cfg["KV_TYPE_K"]; s != "" {
k = s
}
if s := cfg["KV_TYPE_V"]; s != "" {
v = s
}
return k, v
}
// buildServeArgs compose la ligne de commande de llama-server (bin en tête) à
// partir du preset, d'EXTRA_ARGS tel que découpé par splitArgs et de ce que
// cmdServe a sondé. Fonction pure : elle renvoie aussi les variables
// d'environnement à poser et les notes à afficher, sans rien faire elle-même.
// L'ordre des arguments est celui qu'a toujours produit cmdServe — le moteur
// retient la DERNIÈRE occurrence d'un drapeau, donc l'ordre fait partie du
// comportement.
func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveSysInfo) (args []string, env map[string]string, notes []string) {
get := func(key, fallback string) string {
if v, ok := cfg[key]; ok && v != "" {
return v
}
return fallback
}
kv := get("KV_TYPE", "")
ktv := get("KV_TYPE_K", kv)
vtv := get("KV_TYPE_V", kv)
ktv, vtv := serveKVTypes(cfg)
env = cudaDeviceEnv(cfg)
// EXTRA_ARGS est lu ICI, avant de composer la ligne de commande : ce que
// l'utilisateur a écrit à la main décide des défauts que Loki a le droit
// d'ajouter (voir hasAnyFlag). Il est aussi traduit vers les drapeaux de
// chargement actuels quand le moteur les attend (voir normalizeLoadFlags).
extra := normalizeLoadFlags(splitArgs(cfg["EXTRA_ARGS"]), binSupportsLoadMode(bin))
extra, loadNote := normalizeLoadFlags(extra, helpSupportsLoadMode(si.Help))
if loadNote != "" {
notes = append(notes, loadNote)
}
llmArgs := []string{bin,
"-m", model,
// Files de lancement CUDA : une variable d'environnement, pas un drapeau —
// la ligne de commande n'en dépend pas (voir launchQueuesEnv).
q, qNotes := launchQueuesEnv(cfg, extra, si)
if q != "" {
env["CUDA_SCALE_LAUNCH_QUEUES"] = q
}
notes = append(notes, qNotes...)
// Réglages d'expert passés par variable (voir backend_serve_expert.go) :
// absents du preset, ils ne posent rien.
g, gNotes := graphOptEnv(cfg, extra, si)
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...)
args = []string{bin,
"-m", si.Model,
"-c", get("CTX", "32768"),
"-t", get("THREADS", "0"),
"-tb", get("THREADS_BATCH", "0"),
}
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.
@@ -334,7 +541,7 @@ func cmdServe(args []string) error {
// veut vraiment servir plusieurs requêtes à la fois — ou --parallel dans
// EXTRA_ARGS, auquel cas on ne redouble pas le drapeau.
if !hasAnyFlag(extra, "--parallel", "-np") {
llmArgs = append(llmArgs, "--parallel", get("PARALLEL", "1"))
args = append(args, "--parallel", get("PARALLEL", "1"))
}
// Couches GPU. Quatre cas, dans cet ordre :
//
@@ -357,34 +564,26 @@ func cmdServe(args []string) error {
// le DIT sur stderr plutôt que de le faire en douce. Qui veut réellement
// forcer tout sur le GPU écrit NGL=all (ou un nombre qui n'est pas 999).
if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
args, note := nglArgs(get("NGL", ""), binFitsLayersItself(bin))
ngl, note := nglArgs(get("NGL", ""), helpFitsLayersItself(si.Help))
if note != "" {
fmt.Fprintln(os.Stderr, "[loki serve] "+note)
notes = append(notes, note)
}
llmArgs = append(llmArgs, args...)
args = append(args, ngl...)
}
// Marge VRAM par carte du placement automatique (FIT_TARGET, voir
// fitTargetArgs) : seulement si --fit tournera vraiment.
fitt, fitNotes := fitTargetArgs(cfg, extra, si)
args = append(args, fitt...)
notes = append(notes, fitNotes...)
if ktv != "" {
llmArgs = append(llmArgs, "-ctk", ktv)
args = append(args, "-ctk", ktv)
}
if vtv != "" {
llmArgs = append(llmArgs, "-ctv", vtv)
args = append(args, "-ctv", vtv)
}
// Vision : le projecteur multimodal (mmproj-*.gguf) donne des yeux au modèle.
// C'est un fichier .gguf À PART du modèle, chargé via --mmproj. On le résout
// comme le modèle (nom simple cherché dans les dossiers déclarés, ou chemin
// absolu), pour qu'un preset écrit sous Windows reste lançable ailleurs et que
// le champ « Vision » de l'interface n'ait qu'à écrire le nom du fichier.
// Introuvable = on préfère le dire clairement plutôt que laisser llama-server
// mourir en boucle sur un « failed to load mmproj » cryptique.
if mm := strings.TrimSpace(cfg["MMPROJ"]); mm != "" {
mmPath, err := resolveServeModelPath(mm)
if err != nil {
return fmt.Errorf("projecteur vision introuvable : %s (%v)", mm, err)
}
if _, err := os.Stat(mmPath); err != nil {
return fmt.Errorf("projecteur vision introuvable : %s", mmPath)
}
llmArgs = append(llmArgs, "--mmproj", mmPath)
// Vision : chemin déjà résolu et vérifié par cmdServe (voir là-bas).
if si.MMProj != "" {
args = append(args, "--mmproj", si.MMProj)
}
// Raisonnement. Trois cas, et la nuance compte :
//
@@ -404,48 +603,51 @@ func cmdServe(args []string) error {
// contenu). L'anti-boucle côté llm_client.go reste le garde-fou. NE PAS forcer 0 :
// sur llama.cpp vanilla, 0 = "immediate end" → coupe tout le raisonnement
// (le fork ik_llama.cpp l'ignore). Configurable via REASONING_BUDGET.
llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1"))
} else if binSupportsReasoningFlag(bin) {
args = append(args, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1"))
} else if helpSupportsReasoningFlag(si.Help) {
// Pas de budget ici : « off » suffit, et un budget sur un moteur qui
// n'attend rien d'autre ne ferait qu'ajouter une occasion d'échouer.
llmArgs = append(llmArgs, "--reasoning", "off")
args = append(args, "--reasoning", "off")
} else {
// Vieux moteur (ou fork) qui ne connaît pas le drapeau : le lui passer
// le ferait sortir en erreur au démarrage, donc boucler. On le dit et on
// continue sans — mieux vaut un modèle qui réfléchit qu'un moteur mort.
fmt.Fprintf(os.Stderr, "[loki serve] ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement\n")
notes = append(notes, "ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement")
}
}
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
if k, _ := effectiveAPIKeyErr(); k != "" {
llmArgs = append(llmArgs, "--api-key", k)
// Conservation du raisonnement des tours passés : seulement si
// REASONING_PRESERVE est posée (voir reasoningPreserveArgs pour le compromis).
rp, rpNotes := reasoningPreserveArgs(cfg, extra, si)
args = append(args, rp...)
notes = append(notes, rpNotes...)
if si.APIKey != "" {
args = append(args, "--api-key", si.APIKey)
}
// EXTRA_ARGS (déjà découpé plus haut comme le ferait le shell — les guillemets
// gardent ensemble un chemin qui contient des espaces) ferme la marche.
llmArgs = append(llmArgs, extra...)
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
// mmproj-F16.gguf) still resolve.
_ = os.Chdir(LokiHome())
// 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
// 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...)
// 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)
}
warnSlowKV(ktv, vtv)
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
bin, filepath.Base(model), port)
// Hand off to the llama-server process. On Unix this replaces the current
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
return execServer(bin, llmArgs)
notes = append(notes, lossyCacheNotes(extra, si)...)
return args, env, notes
}
// argValue renvoie la valeur de la DERNIÈRE occurrence de flag (llama-server
+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)
}
}
}
+535
View File
@@ -0,0 +1,535 @@
package loki
import (
"context"
"fmt"
"io/fs"
"math"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// Cache de prompts en RAM (--cache-ram) et isolation des travaux annexes.
//
// llama-server garde en RAM hôte une copie EXACTE des états de slot qu'il quitte
// (cache KV, état récurrent, points de reprise, contexte du brouillon MTP) et la
// recharge octet pour octet quand la conversation revient. Ce n'est pas une
// approximation : la seule différence avec un prefill complet est le bruit de
// virgule flottante dû à un découpage des lots différent, exactement comme le
// cache_prompt par défaut. Le type du cache KV n'est pas touché, il est copié
// tel quel.
//
// Le défaut du moteur (8 Gio) est trop petit pour une conversation longue d'un
// modèle dense : l'état principal (30 à 65 k jetons) ne tient plus à côté de
// celui d'un vérificateur ou d'un sous-agent, et la conversation est alors
// recalculée en entier — 30 à 80 s sur un 27B. Loki agrandit donc ce cache,
// mais seulement quand il sait mesurer le besoin et que la RAM est libre : il ne
// descend JAMAIS sous le défaut du moteur, et se tait dès que l'utilisateur a
// tranché (CACHE_RAM, -cram dans EXTRA_ARGS, LLAMA_ARG_CACHE_RAM).
//
// Tout ce qui décide est pur ; probeCacheRAM fait les lectures (GGUF, RAM,
// nvidia-smi) une fois, avant buildServeArgs.
// engineCacheRAMDefault : cache_ram_mib par défaut de llama.cpp (common.h).
const engineCacheRAMDefault = 8192
// cacheArgEnv : la variable qui règle le cache sans drapeau. llama.cpp donne la
// priorité à la ligne de commande : un --cache-ram de Loki l'écraserait.
var cacheArgEnv = []string{"LLAMA_ARG_CACHE_RAM"}
// probeCacheRAM complète si avec ce dont cacheRAMAuto a besoin. Chaque lecture
// ratée laisse un zéro, et un zéro veut dire « on ne sait pas » : le moteur
// garde alors son défaut.
func probeCacheRAM(cfg map[string]string, extra []string, si *serveSysInfo) {
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range cacheArgEnv {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
if si.Model != "" {
si.ModelBytes = shardFamilySize(filepath.Dir(si.Model), filepath.Base(si.Model))
if g, err := ggufMeta(si.Model); err == nil {
si.GGUF = &g
}
}
used, total := ramUsageMB()
if total > 0 {
si.RAMMiB, si.RAMAvailMiB = int64(total), int64(total-used)
}
if runtime.GOOS == "linux" {
if limit, cur := cgroupMemMiB(os.DirFS("/")); limit > 0 {
if si.RAMMiB == 0 || limit < si.RAMMiB {
si.RAMMiB = limit
}
if free := limit - cur; free >= 0 && (si.RAMAvailMiB == 0 || free < si.RAMAvailMiB) {
si.RAMAvailMiB = free
}
}
}
// Mémoire unifiée (macOS), AMD, Vulkan, --device choisi à la main : pas de
// total VRAM fiable, donc pas d'agrandissement.
if runtime.GOOS == "darwin" || hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" {
return
}
// La sélection du preset d'abord (cudaDeviceEnv l'accompagne toujours de
// PCI_BUS_ID), celle de l'environnement ensuite : l'aperçu de l'interface
// tourne dans un process où le preset n'a rien posé.
cvd, pci := cfg["CUDA_VISIBLE_DEVICES"], true
if cvd == "" {
cvd, pci = os.Getenv("CUDA_VISIBLE_DEVICES"), os.Getenv("CUDA_DEVICE_ORDER") == "PCI_BUS_ID"
}
si.VRAMMiB = nvidiaVRAMMiB(cvd, pci, 3*time.Second)
}
// cgroupMemMiB : limite mémoire du conteneur et sa consommation, en Mio (v2,
// sinon v1). 0 = pas de limite. Le MemAvailable de /proc/meminfo voit l'hôte
// entier : sans ça, un conteneur limité à 16 Go sur une machine de 64 Go se
// croirait riche.
func cgroupMemMiB(fsys fs.FS) (limit, current int64) {
read := func(p string) int64 {
b, err := fs.ReadFile(fsys, p)
if err != nil {
return 0
}
n, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
if err != nil || n <= 0 || n >= 1<<60 { // « max » ou la valeur géante du v1 sans limite
return 0
}
return n >> 20
}
if limit = read("sys/fs/cgroup/memory.max"); limit > 0 {
return limit, read("sys/fs/cgroup/memory.current")
}
if limit = read("sys/fs/cgroup/memory/memory.limit_in_bytes"); limit > 0 {
return limit, read("sys/fs/cgroup/memory/memory.usage_in_bytes")
}
return 0, 0
}
// nvidiaVRAMMiB : VRAM totale des cartes que llama-server verra. Borné dans le
// temps comme nvidiaGPUCount ; au moindre doute, 0.
func nvidiaVRAMMiB(cvd string, pciOrder bool, timeout time.Duration) int64 {
if !hasTool("nvidia-smi") {
return 0
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
out, err := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index,memory.total",
"--format=csv,noheader,nounits")).Output()
if err != nil {
return 0
}
return vramFromSMI(string(out), cvd, pciOrder)
}
// vramFromSMI additionne la VRAM des cartes sélectionnées par
// CUDA_VISIBLE_DEVICES. Les index de nvidia-smi sont dans l'ordre du bus PCI ;
// ceux de CUDA aussi, mais seulement avec CUDA_DEVICE_ORDER=PCI_BUS_ID. Sans
// lui, on ne sait pas quelle carte est laquelle : on prend la plus petite autant
// de fois qu'il y a de cartes — un minorant, qui ne fait que rendre Loki plus
// prudent. Un UUID ou une tranche MIG : on ne sait pas, 0.
func vramFromSMI(out, cvd string, pciOrder bool) int64 {
byIdx := map[int]int64{}
var minMiB int64
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
f := strings.Split(line, ",")
if len(f) < 2 {
continue
}
i, err1 := strconv.Atoi(strings.TrimSpace(f[0]))
m, err2 := strconv.ParseInt(strings.TrimSpace(f[1]), 10, 64)
if err1 != nil || err2 != nil || m <= 0 {
return 0
}
byIdx[i] = m
if minMiB == 0 || m < minMiB {
minMiB = m
}
}
if len(byIdx) == 0 {
return 0
}
cvd = strings.TrimSpace(cvd)
if cvd == "" {
var sum int64
for _, m := range byIdx {
sum += m
}
return sum
}
var sum int64
n := 0
for _, d := range strings.Split(cvd, ",") {
if d = strings.TrimSpace(d); d == "" {
continue
}
i, err := strconv.Atoi(d)
if err != nil {
return 0
}
m, ok := byIdx[i]
if !ok {
return 0
}
sum += m
n++
}
if !pciOrder {
return minMiB * int64(n)
}
return sum
}
// kvTypeBytes : octets par élément d'un type de cache (blocs de 32 pour les
// types quantifiés de ggml). Inconnu : compté comme f16, l'estimation reste
// bornée par le plafond de RAM.
func kvTypeBytes(t string) float64 {
switch strings.ToLower(strings.TrimSpace(t)) {
case "f32":
return 4
case "q8_0":
return 34.0 / 32
case "q5_1":
return 24.0 / 32
case "q5_0":
return 22.0 / 32
case "q4_1":
return 20.0 / 32
case "q4_0", "iq4_nl":
return 18.0 / 32
}
return 2
}
// ggufStateBytes estime ce que pèse l'état d'une séquence : octets de cache KV
// par jeton (couches d'attention), et octets d'état récurrent (couches SSM d'un
// hybride, en f32 comme dans llama.cpp). 0 par jeton = on ne sait pas.
func ggufStateBytes(g GGUFInfo, kt, vt string) (perToken float64, recurrent int64) {
kl, vl := g.KeyLen, g.ValLen
if g.EmbeddingLength > 0 && g.HeadCount > 0 {
if kl == 0 {
kl = g.EmbeddingLength / g.HeadCount
}
if vl == 0 {
vl = g.EmbeddingLength / g.HeadCount
}
}
if kl == 0 || vl == 0 || g.BlockCount == 0 {
return 0, 0
}
heads, recLayers := 0, 0
switch {
case len(g.HeadCountKVLayers) > 0:
for _, h := range g.HeadCountKVLayers {
heads += h
if h == 0 && g.Hybrid {
recLayers++
}
}
case g.Hybrid && g.FullAttnInterval > 0:
attn := g.BlockCount / g.FullAttnInterval
heads, recLayers = attn*g.HeadCountKV, g.BlockCount-attn
default:
heads = g.BlockCount * g.HeadCountKV
if heads == 0 && g.Hybrid {
recLayers = g.BlockCount // récurrent pur
}
}
perToken = float64(heads) * (float64(kl)*kvTypeBytes(kt) + float64(vl)*kvTypeBytes(vt))
if recLayers > 0 {
conv := int64(0)
if g.SSMConv > 1 {
conv = int64(g.SSMConv-1) * int64(g.SSMInner+2*g.SSMGroups*g.SSMState)
}
recurrent = int64(recLayers) * (conv + int64(g.SSMState)*int64(g.SSMInner)) * 4
}
return perToken, recurrent
}
// serveCtx : la taille de contexte que le moteur ouvrira (-c d'EXTRA_ARGS, sinon
// CTX, sinon le défaut de Loki ; 0 = celle d'entraînement du modèle).
func serveCtx(cfg map[string]string, extra []string, g *GGUFInfo) int {
s := flagValue(extra, "-c", "--ctx-size")
if s == "" {
s = strings.TrimSpace(cfg["CTX"])
}
if s == "" {
s = "32768"
}
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
return 0
}
if n == 0 && g != nil {
n = g.ContextLength
}
return n
}
// cpuWeights dit pourquoi des poids (ou le cache KV) vivent en RAM hôte. Le
// cache de prompts y ferait alors concurrence au cache de pages des experts
// mappés : on n'y touche pas. Vide = tout est sur GPU.
func cpuWeights(cfg map[string]string, extra []string, argEnv map[string]string, blocks int) string {
ot := flagValues(extra, "-ot", "--override-tensor")
if v := argEnv["LLAMA_ARG_OVERRIDE_TENSOR"]; v != "" {
ot = append(ot, v)
}
for _, v := range ot {
for _, el := range strings.Split(v, ",") {
if _, buft, ok := strings.Cut(el, "="); ok && strings.HasPrefix(strings.ToUpper(strings.TrimSpace(buft)), "CPU") {
return "tenseurs sur CPU (-ot)"
}
}
}
if hasAnyFlag(extra, "--cpu-moe", "-cmoe") || envTruthy(argEnv["LLAMA_ARG_CPU_MOE"]) {
return "experts sur CPU"
}
for _, n := range []struct{ env, short, long string }{
{"LLAMA_ARG_N_CPU_MOE", "-ncmoe", "--n-cpu-moe"},
{"LLAMA_ARG_N_CPU_FFN", "-ncffn", "--n-cpu-ffn"},
} {
for _, v := range append(flagValues(extra, n.short, n.long), argEnv[n.env]) {
if v = strings.TrimSpace(v); v != "" && v != "0" {
return "couches " + n.long + " sur CPU"
}
}
}
if !kvOffloaded(extra, argEnv) {
return "cache KV sur CPU"
}
ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
if ngl == "" {
ngl = strings.TrimSpace(cfg["NGL"])
if strings.EqualFold(ngl, "auto") {
ngl = argEnv["LLAMA_ARG_N_GPU_LAYERS"]
}
}
// 999 est la sentinelle « toutes » ; un nombre plus petit que le modèle (sa
// couche de sortie comprise) laisse des couches au CPU.
if n, err := strconv.Atoi(strings.TrimSpace(ngl)); err == nil && n != 999 && (blocks == 0 || n < blocks+1) {
return "couches GPU limitées à " + strconv.Itoa(n)
}
return ""
}
// cacheRAMAuto calcule la taille du cache de prompts quand CACHE_RAM est vide
// ou « auto ». 0 = garder le défaut du moteur, why dit pourquoi.
//
// Besoin : 2,5 fois l'état d'une conversation pleine (cache KV à CTX jetons,
// plus l'état récurrent et ses points de reprise sur un hybride) — de quoi
// garder la conversation principale ET l'état d'un travail annexe. Plafond : 30
// % de la RAM (limite du conteneur comprise) et la moitié de ce qui est libre au
// lancement. Ce plafond est un réglage, pas un filet : sous Linux, un
// dépassement finit en général par l'OOM killer, pas par le repli du moteur.
func cacheRAMAuto(cfg map[string]string, extra []string, si serveSysInfo) (mib int64, why string) {
switch {
case si.VRAMMiB <= 0:
return 0, "VRAM NVIDIA inconnue"
case si.GGUF == nil:
return 0, "métadonnées GGUF illisibles"
case si.RAMMiB <= 0 || si.RAMAvailMiB <= 0:
return 0, "RAM inconnue"
case si.ModelBytes <= 0:
return 0, "taille du modèle inconnue"
}
if r := cpuWeights(cfg, extra, si.ArgEnv, si.GGUF.BlockCount); r != "" {
return 0, r
}
kt, vt, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
perTok, rec := ggufStateBytes(*si.GGUF, kt, vt)
ctx := serveCtx(cfg, extra, si.GGUF)
if perTok <= 0 || ctx <= 0 {
return 0, "taille d'état inconnue"
}
kvBytes := perTok * float64(ctx)
if float64(si.ModelBytes)+kvBytes > 0.9*float64(si.VRAMMiB)*(1<<20) {
return 0, "modèle trop gros pour la VRAM : des poids restent en RAM"
}
state := kvBytes
if rec > 0 {
// Points de reprise : llama.cpp en garde jusqu'à 32 par séquence ; on en
// compte un par tranche de 8 k jetons, estimation grossière mais bornée.
state += float64(rec) * float64(min(32, ctx/8192+1)+1)
}
need := int64(math.Ceil(2.5 * state / (1 << 20)))
ceiling := min(si.RAMMiB*30/100, si.RAMAvailMiB/2)
if ceiling <= engineCacheRAMDefault {
return 0, fmt.Sprintf("RAM libre trop juste (plafond %d Mio)", ceiling)
}
if need <= engineCacheRAMDefault {
return 0, fmt.Sprintf("le défaut suffit (besoin estimé %d Mio)", need)
}
return min(need, ceiling), fmt.Sprintf("besoin estimé %d Mio, plafond %d Mio", need, ceiling)
}
// cacheRAMSetting lit CACHE_RAM : auto (vide ou « auto »), ou un nombre de Mio
// passé tel quel (-1 = sans limite, 0 = cache coupé). ok=false : valeur
// illisible.
func cacheRAMSetting(cfg map[string]string) (manual string, auto, ok bool) {
v := strings.TrimSpace(cfg["CACHE_RAM"])
if v == "" || strings.EqualFold(v, "auto") {
return "", true, true
}
if n, err := strconv.Atoi(v); err == nil && n >= -1 {
return strconv.Itoa(n), false, true
}
return "", false, false
}
// cacheRAMArgs : le --cache-ram que Loki ajoute, et ce qu'il en dit.
func cacheRAMArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
manual, auto, ok := cacheRAMSetting(cfg)
if !ok {
return nil, []string{"CACHE_RAM=" + cfg["CACHE_RAM"] + " illisible (un nombre de Mio, -1, 0 ou auto) : ignoré"}
}
if !strings.Contains(si.Help, "--cache-ram") {
if !auto {
notes = append(notes, "ce moteur ne connaît pas --cache-ram : CACHE_RAM ignoré")
}
return nil, notes
}
if hasAnyFlag(extra, "-cram", "--cache-ram") {
return nil, nil
}
if si.ArgEnv["LLAMA_ARG_CACHE_RAM"] != "" {
if !auto {
notes = append(notes, "LLAMA_ARG_CACHE_RAM est posée : CACHE_RAM ignoré")
}
return nil, notes
}
if !auto {
return []string{"--cache-ram", manual}, nil
}
n, why := cacheRAMAuto(cfg, extra, si)
if n <= 0 {
return nil, nil
}
return []string{"--cache-ram", strconv.FormatInt(n, 10)},
[]string{fmt.Sprintf("cache de prompts : --cache-ram %d (RAM hôte, copie exacte de l'état, jamais la VRAM ; %s). "+
"CACHE_RAM=<Mio> pour fixer, 0 pour couper.", n, why)}
}
// cacheRAMOff : le cache de prompts est-il coupé (0) par l'une des trois
// sources ? Sans lui, effacer un slot ne protège rien.
func cacheRAMOff(cfg map[string]string, extra []string, argEnv map[string]string) bool {
v := flagValue(extra, "-cram", "--cache-ram")
if v == "" {
v = argEnv["LLAMA_ARG_CACHE_RAM"]
}
if v == "" {
v, _, _ = cacheRAMSetting(cfg)
}
return strings.TrimSpace(v) == "0"
}
// cacheIsolationOn : CACHE_ISOLATE=off coupe l'effacement du slot après les
// travaux annexes (et donc --slot-save-path), sans toucher au code.
func cacheIsolationOn(cfg map[string]string) bool {
return !strings.EqualFold(strings.TrimSpace(cfg["CACHE_ISOLATE"]), "off")
}
// loopbackHost : le moteur n'écoute que sur la machine.
func loopbackHost(h string) bool {
switch strings.Trim(strings.TrimSpace(h), "[]") {
case "127.0.0.1", "::1", "localhost":
return true
}
return false
}
// slotSaveArgs : --slot-save-path, sans lequel llama-server refuse toute action
// sur /slots — l'effacement compris. Mais le drapeau ouvre aussi save et
// restore, qui écrivent des Gio sur le disque, à quiconque joint le moteur. On
// ne le pose donc que si le moteur est fermé aux autres (boucle locale, ou clé
// d'API exigée), que le dossier existe (le moteur refuse de démarrer sinon), et
// que l'isolation servira : cache actif, CACHE_ISOLATE pas à off. Les proxys de
// Loki refusent de leur côté tout /slots qui n'est pas une lecture.
func slotSaveArgs(cfg map[string]string, extra []string, si serveSysInfo) []string {
if si.SlotDir == "" || !strings.Contains(si.Help, "--slot-save-path") ||
hasAnyFlag(extra, "--slot-save-path") || !cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv) {
return nil
}
host := flagValue(extra, "--host")
if host == "" {
host = cfg["HOST"]
}
if host == "" {
host = "0.0.0.0"
}
if !loopbackHost(host) && si.APIKey == "" {
return nil
}
return []string{"--slot-save-path", si.SlotDir}
}
// prepareSlotDir crée LOKI_HOME/slots (0700) et le vide : Loki n'y sauve jamais
// rien, un fichier présent ne peut venir que d'un appel qui n'aurait pas dû
// passer. Vide = dossier indisponible, le drapeau ne sera pas posé.
func prepareSlotDir(home string) string {
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() {
_ = 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)
}
+264
View File
@@ -0,0 +1,264 @@
package loki
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"testing/fstest"
)
const helpCacheRAM = helpRecent + `-cram, --cache-ram N set the maximum cache size in MiB (default: 8192, -1 - no limit, 0 - disable)
--slot-save-path PATH path to save slot kv cache (default: disabled)
`
// denseGPU : un 27B dense de 17 Go sur deux cartes de 24 Go, machine de 64 Go.
// État : 64 couches × 4 têtes KV × (256+256) × 2 octets = 0,25 Mio par jeton.
func denseGPU() serveSysInfo {
return serveSysInfo{
Help: helpCacheRAM,
Model: "/models/dense.gguf",
GGUF: &GGUFInfo{Arch: "qwen3", BlockCount: 64, HeadCountKV: 4, KeyLen: 256, ValLen: 256,
ContextLength: 262144},
ModelBytes: 17 << 30,
RAMMiB: 65536, RAMAvailMiB: 60000,
VRAMMiB: 2 * 24576,
ArgEnv: map[string]string{},
}
}
func TestCacheRAMArgs(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
notes int
}{
{name: "dense sur GPU, 64 Go, 65k : besoin 40 Gio, plafonné à 30 % de la RAM",
cfg: map[string]string{"CTX": "65536"}, want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "contexte court : le défaut du moteur suffit",
cfg: map[string]string{"CTX": "8192"}},
{name: "cache q8_0 : l'état pèse deux fois moins, besoin sous le plafond",
cfg: map[string]string{"CTX": "32768", "KV_TYPE": "q8_0"}, want: []string{"--cache-ram", "10880"}, notes: 1},
{name: "même contexte en f16 : plafonné",
cfg: map[string]string{"CTX": "32768"}, want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "-c d'EXTRA_ARGS l'emporte sur CTX",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-c", "8192"}},
{name: "MoE, experts sur CPU par -ot : jamais touché (pas de 4096)",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "exps=CPU"}},
{name: "-ot mixte avec un élément sur CPU",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "a=CUDA0,b=CPU"}},
{name: "-ot vers une autre carte seulement : tout reste en VRAM",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "blk\\.6.*=CUDA1"},
want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "--n-cpu-moe",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"--n-cpu-moe", "30"}},
{name: "LLAMA_ARG_N_CPU_MOE",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_N_CPU_MOE"] = "20" }},
{name: "cache KV sur CPU",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-nkvo"}},
{name: "NGL plus petit que le modèle",
cfg: map[string]string{"CTX": "65536", "NGL": "40"}},
{name: "NGL=999 : toutes les couches",
cfg: map[string]string{"CTX": "65536", "NGL": "999"}, want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "modèle trop gros pour la VRAM",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ModelBytes = 40 << 30 }},
{name: "pas de VRAM connue (macOS, AMD, sans nvidia-smi)",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.VRAMMiB = 0 }},
{name: "GGUF illisible",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.GGUF = nil }},
{name: "RAM libre trop juste",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.RAMAvailMiB = 12000 }},
{name: "CACHE_RAM fixé : passé tel quel",
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "12000"}, want: []string{"--cache-ram", "12000"}},
{name: "CACHE_RAM=-1 : sans limite, passé tel quel",
cfg: map[string]string{"CACHE_RAM": "-1"}, want: []string{"--cache-ram", "-1"}},
{name: "CACHE_RAM=0 : cache coupé",
cfg: map[string]string{"CACHE_RAM": "0"}, want: []string{"--cache-ram", "0"}},
{name: "CACHE_RAM=auto",
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "auto"}, want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "CACHE_RAM illisible : ignoré, et dit",
cfg: map[string]string{"CACHE_RAM": "beaucoup"}, notes: 1},
{name: "-cram d'EXTRA_ARGS : Loki se tait",
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "12000"}, extra: []string{"-cram", "2048"}},
{name: "LLAMA_ARG_CACHE_RAM : la ligne de commande l'écraserait, Loki se tait",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CACHE_RAM"] = "4096" }},
{name: "LLAMA_ARG_CACHE_RAM et CACHE_RAM : la variable gagne, et on le dit",
cfg: map[string]string{"CACHE_RAM": "12000"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CACHE_RAM"] = "4096" }, notes: 1},
{name: "moteur sans --cache-ram : rien, même fixé",
cfg: map[string]string{"CACHE_RAM": "12000"}, si: func(s *serveSysInfo) { s.Help = helpRecent }, notes: 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := denseGPU()
if c.si != nil {
c.si(&si)
}
got, notes := cacheRAMArgs(c.cfg, c.extra, si)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("args = %v, attendu %v (notes %v)", got, c.want, notes)
}
if len(notes) != c.notes {
t.Fatalf("notes = %v, attendu %d", notes, c.notes)
}
})
}
}
// Hybride (Qwen3.5) : 16 couches d'attention sur 64, état récurrent des 48
// autres, compté avec ses points de reprise.
func TestCacheRAMAutoHybride(t *testing.T) {
si := denseGPU()
layers := make([]int, 64)
for i := range layers {
if (i+1)%4 == 0 {
layers[i] = 4
}
}
si.GGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 64, HeadCountKV: 4, HeadCountKVLayers: layers,
KeyLen: 256, ValLen: 256, FullAttnInterval: 4, Hybrid: true,
SSMConv: 4, SSMInner: 6144, SSMState: 128, SSMGroups: 16}
perTok, rec := ggufStateBytes(*si.GGUF, "", "")
if perTok != 65536 || rec != 156893184 {
t.Fatalf("état = %v o/jeton, %d o récurrents", perTok, rec)
}
// 4 Gio de KV + 10 états récurrents (9 points de reprise + l'état courant),
// × 2,5.
n, why := cacheRAMAuto(map[string]string{"CTX": "65536"}, nil, si)
if n != 13981 {
t.Fatalf("cacheRAMAuto = %d (%s), attendu 13981", n, why)
}
}
// Sans key_length, llama.cpp prend n_embd / n_head : nous aussi.
func TestGGUFStateBytesReplis(t *testing.T) {
g := GGUFInfo{BlockCount: 2, HeadCountKV: 2, EmbeddingLength: 1024, HeadCount: 8}
if p, _ := ggufStateBytes(g, "", ""); p != 2*2*(128+128)*2 {
t.Fatalf("repli n_embd/n_head : %v", p)
}
if p, _ := ggufStateBytes(GGUFInfo{BlockCount: 2, HeadCountKV: 2}, "", ""); p != 0 {
t.Fatalf("dimensions inconnues : %v, attendu 0", p)
}
}
func TestVRAMFromSMI(t *testing.T) {
const out = "0, 24576\n1, 16384\n"
cases := []struct {
cvd string
pci bool
want int64
}{
{"", false, 40960},
{"1", true, 16384},
{"0,1", true, 40960},
{"1", false, 16384}, // ordre inconnu : la plus petite carte
{"0", false, 16384},
{"GPU-1234", true, 0},
{"2", true, 0},
}
for _, c := range cases {
if got := vramFromSMI(out, c.cvd, c.pci); got != c.want {
t.Errorf("vramFromSMI(%q, %v) = %d, attendu %d", c.cvd, c.pci, got, c.want)
}
}
if got := vramFromSMI("[N/A]\n", "", false); got != 0 {
t.Errorf("sortie illisible : %d", got)
}
}
func TestCgroupMemMiB(t *testing.T) {
v2 := fstest.MapFS{
"sys/fs/cgroup/memory.max": {Data: []byte("17179869184\n")},
"sys/fs/cgroup/memory.current": {Data: []byte("4294967296\n")},
}
if l, c := cgroupMemMiB(v2); l != 16384 || c != 4096 {
t.Fatalf("v2 = %d/%d", l, c)
}
if l, _ := cgroupMemMiB(fstest.MapFS{"sys/fs/cgroup/memory.max": {Data: []byte("max\n")}}); l != 0 {
t.Fatalf("v2 sans limite = %d", l)
}
v1 := fstest.MapFS{"sys/fs/cgroup/memory/memory.limit_in_bytes": {Data: []byte("9223372036854771712\n")}}
if l, _ := cgroupMemMiB(v1); l != 0 {
t.Fatalf("v1 sans limite = %d", l)
}
}
func TestSlotSaveArgs(t *testing.T) {
want := []string{"--slot-save-path", "/data/slots"}
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
}{
{name: "boucle locale", cfg: map[string]string{"HOST": "127.0.0.1"}, want: want},
{name: "--host ::1 dans EXTRA_ARGS", extra: []string{"--host", "::1"}, want: want},
{name: "0.0.0.0 sans clé : ouvert à tous, pas de drapeau", cfg: map[string]string{}},
{name: "0.0.0.0 avec clé d'API", cfg: map[string]string{"HOST": "0.0.0.0"},
si: func(s *serveSysInfo) { s.APIKey = "k" }, want: want},
{name: "EXTRA_ARGS le pose déjà", cfg: map[string]string{"HOST": "127.0.0.1"},
extra: []string{"--slot-save-path", "/ailleurs"}},
{name: "CACHE_ISOLATE=off", cfg: map[string]string{"HOST": "127.0.0.1", "CACHE_ISOLATE": "off"}},
{name: "cache coupé : rien à isoler", cfg: map[string]string{"HOST": "127.0.0.1", "CACHE_RAM": "0"}},
{name: "cache coupé par EXTRA_ARGS", cfg: map[string]string{"HOST": "127.0.0.1"}, extra: []string{"-cram", "0"}},
{name: "moteur sans le drapeau", cfg: map[string]string{"HOST": "127.0.0.1"},
si: func(s *serveSysInfo) { s.Help = helpRecent }},
{name: "dossier indisponible", cfg: map[string]string{"HOST": "127.0.0.1"},
si: func(s *serveSysInfo) { s.SlotDir = "" }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := serveSysInfo{Help: helpCacheRAM, SlotDir: "/data/slots", ArgEnv: map[string]string{}}
if c.si != nil {
c.si(&si)
}
if got := slotSaveArgs(c.cfg, c.extra, si); !reflect.DeepEqual(got, c.want) {
t.Fatalf("slotSaveArgs = %v, attendu %v", got, c.want)
}
})
}
}
// Place dans la ligne complète : après --api-key, avant EXTRA_ARGS qui garde le
// dernier mot. Sans aide qui les connaisse, la ligne historique est intacte
// (TestBuildServeArgs).
func TestBuildServeArgsCacheRAM(t *testing.T) {
si := denseGPU()
si.APIKey, si.SlotDir = "k", "/data/slots"
args, _, _ := buildServeArgs(map[string]string{"CTX": "65536", "NGL": "999"}, []string{"--jinja"}, "/bin/llama-server", si)
got := strings.Join(args, " ")
if !strings.HasSuffix(got, "--api-key k --cache-ram 19660 --slot-save-path /data/slots --jinja") {
t.Fatalf("ligne = %s", got)
}
}
func TestPrepareSlotDir(t *testing.T) {
home := t.TempDir()
dir := prepareSlotDir(home)
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) == "" {
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); got != "" {
t.Fatalf("slots est un fichier : %q", got)
}
}
+325
View File
@@ -0,0 +1,325 @@
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 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é.
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)
}
}
+207
View File
@@ -0,0 +1,207 @@
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"}
// 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))
}
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
})
}
}
+6 -1
View File
@@ -60,10 +60,15 @@ func TestNormalizeLoadFlags(t *testing.T) {
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := normalizeLoadFlags(c.in, c.supports)
got, note := normalizeLoadFlags(c.in, c.supports)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("got %q, want %q", got, c.want)
}
// Seul « dio » abandonné sur un moteur ancien se signale : les autres
// traductions gardent le sens demandé.
if wantNote := c.name == "moteur ancien : dio abandonné"; (note != "") != wantNote {
t.Fatalf("note = %q, attendue : %v", note, wantNote)
}
})
}
}
+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)
}
}
+534
View File
@@ -0,0 +1,534 @@
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
//
// 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.
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
)
// 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) ou charger deux brouillons.
var specUserFlags = []string{"--spec-type", "-md", "--model-draft", "--spec-draft-model",
"-hfd", "-hfrd", "--spec-draft-hf", "--hf-repo-draft", "--spec-default"}
// probeSpec : variables du moteur, puis MODEL_DRAFT résolu comme MMPROJ (nom
// simple cherché dans les dossiers déclarés, ou chemin absolu). Introuvable ne
// bloque PAS le lancement, contrairement au projecteur : le brouillon n'est
// qu'une accélération, le moteur démarre sans et la note dit pourquoi.
func probeSpec(cfg map[string]string, si *serveSysInfo) {
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range specArgEnv {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
d := strings.TrimSpace(cfg["MODEL_DRAFT"])
if d == "" {
return
}
p, err := resolveServeModelPath(d)
if err == nil {
_, err = os.Stat(p)
}
if err != nil {
si.DraftErr = err.Error()
return
}
si.Draft = p
if g, err := ggufMeta(p); err == nil {
si.DraftGGUF = &g
}
}
// specSidecarArch : têtes de spéculation qui ne sont pas des modèles autonomes
// (llama.cpp : draft-eagle3, draft-dflash/dspark). Un -md vers elles avec
// draft-simple ne démarrerait pas : leur type se règle à la main.
func specSidecarArch(arch string) bool {
switch strings.ToLower(strings.TrimSpace(arch)) {
case "eagle3", "dflash":
return true
}
return false
}
// specMode lit SPEC : "off", "auto", "mtp", 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":
return v
}
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...) {
return true
}
for _, k := range []string{"LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL", "LLAMA_ARG_SPEC_DRAFT_HF_REPO"} {
if argEnv[k] != "" {
return true
}
}
return false
}
// helpSupportsMTP : le moteur connaît le type draft-mtp ET le drapeau actuel
// du nombre de jetons — un moteur qui n'a que l'ancien --draft-max est d'une
// autre génération, où un MTP n'existe pas.
func helpSupportsMTP(help string) bool {
return strings.Contains(help, "draft-mtp") && strings.Contains(help, "--spec-draft-n-max")
}
// ctxFixed : le contexte que verra le moteur (EXTRA_ARGS, sinon CTX, sinon le
// défaut de buildServeArgs), et s'il est chiffré. Avec 0 ou une valeur
// illisible, --fit a le droit de RÉDUIRE le contexte pour tenir.
func ctxFixed(cfg map[string]string, extra []string) (string, bool) {
c := flagValue(extra, "-c", "--ctx-size")
if c == "" {
c = strings.TrimSpace(cfg["CTX"])
}
if c == "" {
c = "32768"
}
n, err := strconv.Atoi(c)
return c, err == nil && n > 0
}
// statefulSampler : un échantillonneur dont l'état avance à chaque tirage
// (mirostat, adaptive-p). Le rejet probabiliste fait d'abord tirer le modèle
// cible, puis peut émettre un autre jeton : ces échantillonneurs avanceraient
// sur un jeton que la réponse ne contient pas. Le mode greedy, lui, reste exact.
func statefulSampler(extra []string) string {
for _, v := range flagValues(extra, "--mirostat") {
if v = strings.TrimSpace(v); v != "" && v != "0" {
return "--mirostat " + v
}
}
for _, v := range flagValues(extra, "--samplers") {
if strings.Contains(strings.ToLower(strings.ReplaceAll(v, "-", "_")), "adaptive_p") {
return "--samplers adaptive-p"
}
}
for _, v := range flagValues(extra, "--sampling-seq", "--sampler-seq") {
if strings.Contains(v, "a") {
return "--sampling-seq avec adaptive-p"
}
}
return ""
}
// specAutoBlocker dit pourquoi SPEC=auto ne s'active pas, ou vide. Chaque
// garde répond à une panne précise :
//
// - qwen4exp : MTP fusionné il y a quelques jours, OOM signalés en multi-GPU ;
// - placement manuel (couches fixées, -ts, -ot, experts sur CPU, -sm row,
// --fit off, -dev) : seul --fit compte la VRAM de la tête MTP ;
// - contexte non chiffré : fit aurait le droit de le RÉDUIRE pour faire de
// la place — de l'information perdue pour le modèle ;
// - projecteur vision : une VRAM déjà serrée par un second modèle ;
// - build officiel inconnu ou trop ancien ;
// - un essai précédent avec la même configuration n'a jamais répondu.
func specAutoBlocker(cfg map[string]string, extra []string, si serveSysInfo) string {
if si.GGUF != nil && strings.EqualFold(si.GGUF.Arch, "qwen4exp") {
return "qwen4exp : MTP trop récent pour ce moteur, SPEC=mtp pour l'imposer"
}
if why := fitBlocker(cfg, extra, si); why != "" {
return "placement manuel (" + why + ") — --fit ne compte pas la VRAM du brouillon"
}
if hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" {
return "placement manuel (--device) — --fit ne compte pas la VRAM du brouillon"
}
if _, ok := ctxFixed(cfg, extra); !ok {
return "contexte non chiffré — fit pourrait le réduire pour loger le brouillon"
}
if si.MMProj != "" || hasAnyFlag(extra, "--mmproj", "-mm") {
return "projecteur vision chargé"
}
if si.EngineBuild < specAutoMinBuild {
if si.EngineBuild <= 0 {
return "build du moteur inconnu (compilé ou fork)"
}
return fmt.Sprintf("moteur b%d, b%d au moins", si.EngineBuild, specAutoMinBuild)
}
return si.SpecAutoBlocked
}
// specArgs : drapeaux de décodage spéculatif que Loki ajoute, ce qu'il en dit,
// et s'ils viennent de SPEC=auto (cmdServe pose alors le jeton de tentative).
// Fonction pure ; cmdServe a déjà résolu MODEL_DRAFT et lu les GGUF.
func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string, auto bool) {
mode := specMode(cfg)
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
switch mode {
case "":
return nil, []string{"SPEC=" + cfg["SPEC"] + " illisible (off, auto ou mtp) : 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
}
// La source : MODEL_DRAFT s'il est posé, sinon la tête MTP du modèle. Dans
// les deux cas, on regarde les TENSEURS, comme llama.cpp : une clé
// nextn_predict_layers seule ne prouve pas que la tête est dans le fichier.
mtp := false
switch {
case draftKey != "":
switch {
case si.Draft == "":
why := "MODEL_DRAFT=" + draftKey + " introuvable"
if si.DraftErr != "" {
why += " (" + si.DraftErr + ")"
}
return skip(why)
case si.DraftGGUF == nil:
return skip("MODEL_DRAFT illisible (GGUF incomplet ou en cours de téléchargement ?)")
case si.DraftGGUF.HasNextNTensor:
if !helpSupportsMTP(si.Help) {
return skip("ce moteur ne connaît pas draft-mtp")
}
// Type explicite : llama.cpp ne le devine que sur la première tranche.
args, mtp = []string{"-md", si.Draft, "--spec-type", "draft-mtp"}, true
case specSidecarArch(si.DraftGGUF.Arch):
return skip("MODEL_DRAFT est une tête " + si.DraftGGUF.Arch +
", pas un modèle brouillon : règle -md et --spec-type dans EXTRA_ARGS")
default:
if !strings.Contains(si.Help, "--spec-type") || !strings.Contains(si.Help, "draft-simple") {
return skip("ce moteur ne connaît pas --spec-type draft-simple")
}
// Sans type, un brouillon qui n'est pas une tête MTP serait chargé en
// VRAM puis jamais utilisé.
args = []string{"-md", si.Draft, "--spec-type", "draft-simple"}
}
case si.GGUF == nil:
return skip("métadonnées du modèle illisibles")
case !si.GGUF.HasNextNTensor:
if mode == "mtp" || si.GGUF.NextN > 0 {
return skip("pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)")
}
return nil, nil, false // auto sur un modèle sans MTP : rien à dire
case !helpSupportsMTP(si.Help):
return skip("ce moteur ne connaît pas draft-mtp")
default:
args, mtp = []string{"--spec-type", "draft-mtp"}, true
}
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=mtp → " + label + " imposé : sortie inchangée, ~1-2 Go de VRAM en plus"
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)
}
// 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 != "mtp":
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
}
// --- 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
}
+334
View File
@@ -0,0 +1,334 @@
package loki
import (
"reflect"
"slices"
"strings"
"testing"
)
const helpSpec = helpRecent + `--spec-draft-n-max N number of tokens to draft for speculative decoding (default: 3)
--spec-draft-sampling {greedy,probabilistic}
how the draft is sampled (default: greedy)
--spec-type [none,ngram-cache,ngram-simple,ngram-map-k,ngram-map-k4v,ngram-mod,draft-simple,draft-eagle3,draft-mtp,draft-dflash,draft-dspark]
comma-separated list of types of speculative decoding to use (default: none)
`
// helpSpecOld : un moteur qui connaît --spec-type mais pas encore le MTP.
const helpSpecOld = helpRecent + `--spec-type [none,ngram-cache,ngram-simple,draft-simple]
`
// mtpReady : un Qwen3.6 27B avec sa tête MTP dans le fichier, placé par --fit,
// sur un moteur officiel récent. Le cas où SPEC=auto a le droit d'agir.
func mtpReady() serveSysInfo {
return serveSysInfo{
Help: helpSpec,
Model: "/models/Qwen3.6-27B-MTP.gguf",
GGUF: &GGUFInfo{Arch: "qwen35", BlockCount: 65, NextN: 1, HasNextNTensor: true, Hybrid: true},
ArgEnv: map[string]string{},
EngineBuild: 11351,
}
}
func TestSpecArgs(t *testing.T) {
mtp := []string{"--spec-type", "draft-mtp", "--spec-draft-sampling", "greedy"}
auto := map[string]string{"SPEC": "auto"}
forced := map[string]string{"SPEC": "mtp"}
with := func(base map[string]string, kv ...string) map[string]string {
m := map[string]string{}
for k, v := range base {
m[k] = v
}
for i := 0; i+1 < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
}
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
wantAuto bool
notes int
}{
{name: "SPEC absent : rien, même sur un modèle MTP (opt-in)", cfg: map[string]string{}},
{name: "SPEC=off : rien", cfg: map[string]string{"SPEC": "off"}},
{name: "SPEC=off avec MODEL_DRAFT : rien, et on le dit",
cfg: map[string]string{"SPEC": "off", "MODEL_DRAFT": "mtp-Qwen3.6-27B.gguf"}, notes: 1},
{name: "SPEC illisible : rien, et on le dit", cfg: map[string]string{"SPEC": "turbo"}, notes: 1},
{name: "auto, tête MTP dans le fichier, --fit actif : draft-mtp", cfg: auto, want: mtp, wantAuto: true, notes: 1},
{name: "auto, clé nextn sans tenseur (tête publiée à part) : rien",
cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.GGUF.HasNextNTensor = false }},
{name: "auto, modèle sans MTP : rien, sans bruit",
cfg: auto, si: func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "llama", BlockCount: 32} }},
{name: "auto, métadonnées illisibles : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.GGUF = nil }},
{name: "--spec-type dans EXTRA_ARGS : Loki se tait", cfg: auto, extra: []string{"--spec-type", "ngram-mod"}, notes: 1},
{name: "--model-draft=… dans EXTRA_ARGS : Loki se tait", cfg: auto, extra: []string{"--model-draft=d.gguf"}, notes: 1},
{name: "-hfd dans EXTRA_ARGS : Loki se tait", cfg: auto, extra: []string{"-hfd", "u/r"}, notes: 1},
{name: "--spec-default dans EXTRA_ARGS : Loki se tait", cfg: auto, extra: []string{"--spec-default"}, notes: 1},
{name: "LLAMA_ARG_SPEC_TYPE posée : Loki se tait", cfg: auto, notes: 1,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SPEC_TYPE"] = "draft-mtp" }},
{name: "auto, --tensor-split : rien", cfg: auto, extra: []string{"--tensor-split", "0.965,0.035"}, notes: 1},
{name: "auto, -ts=… : rien", cfg: auto, extra: []string{"-ts=1,1"}, notes: 1},
{name: "auto, NGL chiffré : rien", cfg: with(auto, "NGL", "40"), notes: 1},
{name: "auto, -ngl 99 dans EXTRA_ARGS : rien", cfg: auto, extra: []string{"-ngl", "99"}, notes: 1},
{name: "auto, -ot : rien", cfg: auto, extra: []string{"-ot", "exps=CPU"}, notes: 1},
{name: "auto, --n-cpu-moe : rien", cfg: auto, extra: []string{"--n-cpu-moe", "20"}, notes: 1},
{name: "auto, -cmoe : rien", cfg: auto, extra: []string{"-cmoe"}, notes: 1},
{name: "auto, -sm row : rien", cfg: auto, extra: []string{"-sm", "row"}, notes: 1},
{name: "auto, -fit off : rien", cfg: auto, extra: []string{"-fit", "off"}, notes: 1},
{name: "auto, -dev : rien", cfg: auto, extra: []string{"-dev", "CUDA0"}, notes: 1},
{name: "auto, LLAMA_ARG_DEVICE : rien", cfg: auto, notes: 1,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_DEVICE"] = "CUDA0" }},
{name: "auto, CTX=0 (fit pourrait réduire le contexte) : rien", cfg: with(auto, "CTX", "0"), notes: 1},
{name: "auto, projecteur vision : rien", cfg: auto, notes: 1,
si: func(s *serveSysInfo) { s.MMProj = "/models/mmproj-F16.gguf" }},
{name: "auto, build inconnu : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.EngineBuild = 0 }},
{name: "auto, moteur trop ancien : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.EngineBuild = 10900 }},
{name: "auto, qwen4exp : jamais d'office", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.GGUF.Arch = "qwen4exp" }},
{name: "auto, essai précédent sans réponse : rien", cfg: auto, notes: 1,
si: func(s *serveSysInfo) {
s.SpecAutoBlocked = "le dernier lancement avec cette configuration n'a jamais répondu"
}},
{name: "auto, moteur sans draft-mtp : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpecOld }},
{name: "auto, aide illisible : rien", cfg: auto, notes: 1, si: func(s *serveSysInfo) { s.Help = "" }},
{name: "mtp imposé malgré -ts : drapeaux, avec l'avertissement", cfg: forced,
extra: []string{"-ts", "0.965,0.035"}, want: mtp, notes: 1},
{name: "mtp imposé sur qwen4exp, build inconnu", cfg: forced, want: mtp, notes: 1,
si: func(s *serveSysInfo) { s.GGUF.Arch = "qwen4exp"; s.EngineBuild = 0 }},
{name: "mtp imposé, moteur sans draft-mtp : rien", cfg: forced, notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpecOld }},
{name: "MODEL_DRAFT tête MTP à part : -md chemin absolu + draft-mtp",
cfg: with(auto, "MODEL_DRAFT", "mtp-Qwen3.6-27B-Q8_0.gguf"), wantAuto: true, notes: 1,
want: []string{"-md", "/models/mtp-Qwen3.6-27B-Q8_0.gguf", "--spec-type", "draft-mtp", "--spec-draft-sampling", "greedy"},
si: func(s *serveSysInfo) {
s.GGUF.HasNextNTensor = false
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
}},
{name: "MODEL_DRAFT petit modèle : -md + draft-simple, sinon chargé pour rien",
cfg: with(forced, "MODEL_DRAFT", "Qwen3-0.6B.gguf"), notes: 1,
want: []string{"-md", "/models/Qwen3-0.6B.gguf", "--spec-type", "draft-simple", "--spec-draft-sampling", "greedy"},
si: func(s *serveSysInfo) {
s.Draft = "/models/Qwen3-0.6B.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "qwen3", BlockCount: 28}
}},
{name: "MODEL_DRAFT tête dFlash : pas de draft-simple, réglage à la main",
cfg: with(forced, "MODEL_DRAFT", "dflash-Qwen3.6.gguf"), notes: 1,
si: func(s *serveSysInfo) {
s.Draft = "/models/dflash-Qwen3.6.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "dflash", BlockCount: 5}
}},
{name: "MODEL_DRAFT tête EAGLE-3 : idem",
cfg: with(auto, "MODEL_DRAFT", "eagle3.gguf"), notes: 1,
si: func(s *serveSysInfo) {
s.Draft = "/models/eagle3.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "eagle3", BlockCount: 1}
}},
{name: "MODEL_DRAFT introuvable : moteur lancé sans, pas d'erreur",
cfg: with(auto, "MODEL_DRAFT", "absent.gguf"), notes: 1,
si: func(s *serveSysInfo) { s.DraftErr = "fichier introuvable" }},
{name: "MODEL_DRAFT illisible : rien", cfg: with(auto, "MODEL_DRAFT", "mtp-x.gguf"), notes: 1,
si: func(s *serveSysInfo) { s.Draft = "/models/mtp-x.gguf" }},
{name: "SPEC_N_MAX=2", cfg: with(auto, "SPEC_N_MAX", "2"), wantAuto: true, notes: 1,
want: []string{"--spec-type", "draft-mtp", "--spec-draft-n-max", "2", "--spec-draft-sampling", "greedy"}},
{name: "SPEC_N_MAX illisible : défaut du moteur", cfg: with(auto, "SPEC_N_MAX", "beaucoup"), want: mtp, wantAuto: true, notes: 2},
{name: "--spec-draft-n-max dans EXTRA_ARGS : SPEC_N_MAX se tait, MTP reste",
cfg: with(auto, "SPEC_N_MAX", "2"), extra: []string{"--spec-draft-n-max", "4"}, want: mtp, wantAuto: true, notes: 1},
{name: "probabilistic jamais avec auto",
cfg: with(auto, "SPEC_SAMPLING", "probabilistic"), want: mtp, wantAuto: true, notes: 2},
{name: "probabilistic avec mtp imposé", cfg: with(forced, "SPEC_SAMPLING", "probabilistic"), notes: 1,
want: []string{"--spec-type", "draft-mtp", "--spec-draft-sampling", "probabilistic"}},
{name: "probabilistic refusé avec mirostat", cfg: with(forced, "SPEC_SAMPLING", "probabilistic"),
extra: []string{"--mirostat", "2"}, want: mtp, notes: 2},
{name: "probabilistic refusé avec adaptive-p", cfg: with(forced, "SPEC_SAMPLING", "probabilistic"),
extra: []string{"--samplers", "top_k;adaptive_p"}, want: mtp, notes: 2},
{name: "--spec-draft-sampling dans EXTRA_ARGS : on ne le double pas", cfg: auto,
extra: []string{"--spec-draft-sampling", "greedy"}, want: []string{"--spec-type", "draft-mtp"}, wantAuto: true, notes: 1},
{name: "moteur sans --spec-draft-sampling : pas de drapeau", cfg: auto, wantAuto: true, notes: 1,
want: []string{"--spec-type", "draft-mtp"},
si: func(s *serveSysInfo) {
s.Help = strings.Replace(s.Help, "--spec-draft-sampling {greedy,probabilistic}", "", 1)
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := mtpReady()
if c.si != nil {
c.si(&si)
}
got, notes, isAuto := specArgs(c.cfg, c.extra, si)
if !reflect.DeepEqual(got, c.want) {
t.Errorf("args = %q, attendu %q", got, c.want)
}
if isAuto != c.wantAuto {
t.Errorf("auto = %v, attendu %v", isAuto, c.wantAuto)
}
if len(notes) != c.notes {
t.Errorf("%d notes, attendu %d : %q", len(notes), c.notes, notes)
}
if specMode(c.cfg) == "auto" && slices.Contains(got, "probabilistic") {
t.Errorf("SPEC=auto ne doit jamais choisir le tirage probabiliste : %q", got)
}
for _, a := range got {
if a == "--draft" || a == "--draft-max" || a == "--draft-n" || a == "-ctkd" || a == "-ctvd" {
t.Errorf("drapeau interdit %q", a)
}
}
})
}
}
// La spéculation passe avant EXTRA_ARGS, qui garde le dernier mot.
func TestBuildServeArgsSpec(t *testing.T) {
si := mtpReady()
args, _, _ := buildServeArgs(map[string]string{"SPEC": "auto"}, []string{"--jinja"}, "/bin/llama-server", si)
i := slices.Index(args, "--spec-type")
if i < 0 || args[i+1] != "draft-mtp" {
t.Fatalf("--spec-type draft-mtp absent : %q", args)
}
if j := slices.Index(args, "--jinja"); j < i {
t.Errorf("EXTRA_ARGS doit fermer la marche : %q", args)
}
args, _, _ = buildServeArgs(map[string]string{}, nil, "/bin/llama-server", si)
if slices.Contains(args, "--spec-type") {
t.Errorf("SPEC absent : aucun drapeau attendu, %q", args)
}
}
func TestStatefulSampler(t *testing.T) {
cases := map[string][]string{
"": {"--mirostat", "0"},
"--mirostat 1": {"--mirostat=1"},
"--samplers adaptive-p": {"--samplers", "penalties;adaptive-p"},
"--sampling-seq avec adaptive-p": {"--sampling-seq", "kpa"},
}
for want, extra := range cases {
if got := statefulSampler(extra); got != want {
t.Errorf("statefulSampler(%q) = %q, attendu %q", extra, got, want)
}
}
if got := statefulSampler([]string{"--samplers", "top_k;top_p;temperature"}); got != "" {
t.Errorf("chaîne sans état : %q", got)
}
}
func TestSpecAutoVerdict(t *testing.T) {
cur := specAutoMark{FP: "abc", Bin: "/e/llama-server", Build: 11351}
other := specAutoMark{FP: "abc", Bin: "/e/llama-server", Build: 11400}
if why, rec := specAutoVerdict(cur, nil, nil); why != "" || rec {
t.Errorf("aucun essai : permis, got %q %v", why, rec)
}
if why, rec := specAutoVerdict(cur, &cur, map[string]string{}); why == "" || !rec {
t.Errorf("jeton resté pour la même combinaison : coupé et inscrit, got %q %v", why, rec)
}
if why, rec := specAutoVerdict(cur, &other, map[string]string{}); why != "" || rec {
t.Errorf("jeton d'un autre build : permis (mise à jour = nouvel essai), got %q %v", why, rec)
}
if why, rec := specAutoVerdict(cur, nil, map[string]string{cur.key(): "OOM"}); why != "OOM" || rec {
t.Errorf("échec inscrit : coupé, got %q %v", why, rec)
}
}
// Le cycle complet sur une vraie base : jeton posé, jamais effacé, puis coupé
// pour cette combinaison seulement.
func TestSpecAutoCheckStore(t *testing.T) {
testHome(t)
// Ce que fait cmdServe : lire, puis — port libre — ranger et poser le jeton.
launch := func(cur specAutoMark, wantAttempt bool) string {
why, record := specAutoPeek(cur)
specAutoSettle(cur, why, record, wantAttempt && why == "")
return why
}
cur := specAutoMark{FP: "abc", Bin: "/e/llama-server", Build: 11351}
if why := launch(cur, true); why != "" {
t.Fatalf("base vide : permis, got %q", why)
}
// Second « loki serve » refusé (port pris) : il a lu, sans rien ranger. Le
// jeton du premier, qui charge encore, doit rester intact.
if why, _ := specAutoPeek(cur); why == "" {
t.Fatal("jeton présent : le lecteur doit le voir")
}
var a specAutoMark
if !getJSON(bkState, specAttemptKey, &a) || a != cur {
t.Fatal("specAutoPeek ne doit rien consommer")
}
if why := launch(cur, true); why == "" {
t.Fatal("jeton resté : l'auto doit être coupé")
}
if getJSON(bkState, specAttemptKey, &a) {
t.Error("auto coupé : aucun nouveau jeton")
}
if why := launch(cur, true); why == "" {
t.Error("l'échec doit rester inscrit au lancement suivant")
}
updated := cur
updated.Build = 11400
if why := launch(updated, true); why != "" {
t.Errorf("moteur mis à jour : nouvel essai permis, got %q", why)
}
// Le process web efface SON jeton ; pas celui d'un lancement plus récent.
specAttemptClear(cur, "")
if !getJSON(bkState, specAttemptKey, &a) || a != updated {
t.Fatal("le jeton d'un autre lancement doit rester")
}
specAttemptClear(updated, "")
if getJSON(bkState, specAttemptKey, &a) {
t.Fatal("le moteur a répondu : jeton effacé")
}
if why := launch(updated, true); why != "" {
t.Errorf("lancement réussi : permis, got %q", why)
}
// Répondu mais couches en RAM : échec inscrit pour cette combinaison.
specAttemptClear(updated, "couches en RAM")
if why, _ := specAutoPeek(updated); why != "couches en RAM" {
t.Errorf("échec après chargement : coupé, got %q", why)
}
// Un jeton d'une autre configuration est jeté sans rien conclure.
other := specAutoMark{FP: "zzz", Bin: "/e/llama-server", Build: 11351}
specAutoSettle(other, "", false, true)
if why := launch(cur, false); why == "" {
t.Error("échec de cur toujours inscrit")
}
if getJSON(bkState, specAttemptKey, &a) {
t.Error("le jeton étranger doit être consommé")
}
}
func TestLastOffload(t *testing.T) {
log := strings.Join([]string{
"srv load_model: loading model '/models/old.gguf'",
"load_tensors: offloaded 40/65 layers to GPU",
"srv load_model: loading model '/models/new.gguf'",
"load_tensors: offloaded 65/65 layers to GPU",
"main: server is listening",
}, "\n")
if n, m := lastOffload(log); n != 65 || m != 65 {
t.Errorf("dernier chargement seulement : got %d/%d", n, m)
}
if n, m := lastOffload("load_tensors: offloaded 10/65 layers to GPU"); n != 0 || m != 0 {
t.Errorf("sans ligne de chargement, on ne conclut rien : got %d/%d", n, m)
}
if n, m := lastOffload("srv load_model: loading model 'x'\nload_tensors: offloaded 50/65 layers to GPU"); n != 50 || m != 65 {
t.Errorf("chargement partiel : got %d/%d", n, m)
}
// Le journal réel de llama-server : « load_model: initializing » APRÈS le
// chargement, et un brouillon chargé à part qui a ses propres lignes.
srvLog := strings.Join([]string{
"srv load_model: loading model '/models/Qwen3.6-27B.gguf'",
"load_tensors: loading model tensors, this can take a while... (load_mode = mmap)",
"load_tensors: offloaded 52/65 layers to GPU",
"load_tensors: loading model tensors, this can take a while... (load_mode = mmap)",
"load_tensors: offloaded 2/2 layers to GPU",
"srv load_model: initializing, n_slots = 1, n_ctx_slot = 65536, kv_unified = 'false'",
"main: server is listening on http://127.0.0.1:8080",
}, "\n")
if n, m := lastOffload(srvLog); n != 52 || m != 65 {
t.Errorf("modèle partiel, brouillon complet : le pire compte, got %d/%d", n, m)
}
full := strings.Replace(srvLog, "52/65", "65/65", 1)
if n, m := lastOffload(full); n != m || m == 0 {
t.Errorf("tout sur GPU : got %d/%d", n, m)
}
}
+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)
}
}
}
+2 -1
View File
@@ -73,7 +73,8 @@ func cmdChat(args []string) error {
msgs = compacted
fmt.Println(dim("[contexte compacté pour tenir dans la fenêtre]"))
}
extra, err := runChat(context.Background(), InjectSkills(msgs, caps), 0.7, caps, func(ev StreamEvent) bool {
sent, tools := prepareTurn(msgs, caps)
extra, err := runChatTools(context.Background(), sent, tools, 0.7, caps, func(ev StreamEvent) bool {
switch {
case ev.Err != nil:
fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error()))
+186 -21
View File
@@ -215,6 +215,69 @@ func compactWouldTrigger(msgs []Message, knownTokens int) bool {
return used >= int(float64(ctxWindow())*compactTriggerFrac)
}
// compactNeeded = compactWouldTrigger, plus une garde de MARGE DE GÉNÉRATION.
// Tant que le raisonnement de la dernière étape était compté dans le contexte
// (à tort : il n'est jamais renvoyé), il offrait par accident une marge à peu
// près proportionnelle à la longueur des étapes. Ce compte est désormais juste,
// et sans max_tokens ni budget de raisonnement, une étape aussi longue que la
// plus longue récente doit encore tenir : sinon le moteur coupe en plein
// raisonnement (« length »). peakGen = 0 (inconnu, API externe) : seuil seul.
func compactNeeded(msgs []Message, used, peakGen int) bool {
if compactWouldTrigger(msgs, used) {
return true
}
return compactEnabled() && genHeadroomShort(used, peakGen, ctxWindow())
}
// genHeadroomShort : la fenêtre ne laisse plus la place d'une génération de
// 1,2 × la plus longue récente (plancher de 8 k jetons, ramené à 1/8 de la
// fenêtre pour les petites), plus une petite marge. Fonction pure, testable.
// Plancher et marge sont choisis pour ne JAMAIS devancer le seuil de 75 % à eux
// seuls : seule une génération longue avance la compaction.
//
// Et jamais avant la MOITIÉ de la fenêtre, si longue que soit la génération :
// une étape de 40 k sur 65 k (ou de 20 k sur 32 k) réclamait sinon une place
// que l'historique compacté lui-même — prompt système, outils, résumé, queue —
// ne laisse pas. La garde repartait alors à CHAQUE étape, résumé de résumé, le
// fil perdu pour rien. Au-delà, c'est le rejeu sur « length » qui rattrape.
func genHeadroomShort(used, peakGen, window int) bool {
if used <= 0 || peakGen <= 0 || window <= 0 {
return false
}
need := max(peakGen*6/5, min(8192, window/8)) + min(1024, window/32)
return used+min(need, window/2) > window
}
// logCtx trace un fait du comptage de contexte (fenêtre pleine, relance après
// un raisonnement sans action) : de quoi vérifier, journal en main, qu'ils ne
// deviennent pas plus fréquents.
func logCtx(format string, args ...any) {
fmt.Fprintf(os.Stderr, "[ctx] "+format+"\n", args...)
}
// logCtxEstimate compare le contexte ESTIMÉ avant une requête au compte réel
// du moteur (usage.prompt_tokens), et ne parle qu'au-delà de 2 % d'écart.
func logCtxEstimate(phase string, est, actual int) {
if actual <= 0 {
return
}
if d := est - actual; d*50 > actual || -d*50 > actual {
logCtx("%s estimé=%d réel=%d écart=%+d", phase, est, actual, d)
}
}
// ctxPending ajoute au dernier contexte mesuré (used, valable pour les
// measured premiers messages) l'estimation des messages arrivés depuis : le
// nouveau message utilisateur, ceux de la file. Le compte du moteur ne les a
// pas vus. measured = 0 (inconnu, discussion rechargée) ou plus grand que
// l'historique (réécrit entre-temps) : on s'en tient au compte mesuré.
func ctxPending(used, measured int, msgs []Message) int {
if used <= 0 || measured <= 0 || measured > len(msgs) {
return used
}
return used + estimateTokens(msgs[measured:])
}
// logCompact trace UNE ligne par décision de compaction sur la sortie d'erreur
// (donc dans `journalctl -u loki-ui`). Sans ça, une compaction qui ne se
// déclenche pas — ou qui se déclenche et n'enlève rien — est invisible : côté
@@ -275,7 +338,14 @@ func compactBounds(msgs []Message, tailBudget int) (head, tailStart int) {
break
}
}
for tailStart > head && msgs[tailStart].Role != "user" && msgs[tailStart].Role != "assistant" {
// Jamais sur un rappel de loki (isLokiInjected) : il a le rôle `user` sans
// être une demande. En tête de queue, la vraie demande réinjectée juste
// avant (pending, voir compactMessages) lui serait collée — deux `user`
// d'affilée, que les gabarits à alternance stricte refusent à chaque tour.
// On recule jusqu'à l'appel d'outil qui le précède : la queue garde un
// groupe de plus, rien n'est perdu.
for tailStart > head && ((msgs[tailStart].Role != "user" && msgs[tailStart].Role != "assistant") ||
isLokiInjected(msgs[tailStart])) {
tailStart--
}
return head, tailStart
@@ -426,9 +496,13 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
// répondre le modèle une seconde fois à une vieille question ;
// - repli sans résumé (mid = torse dégraissé) : ce message y est déjà, il se
// retrouvait en double, cette fois APRÈS ses propres résultats d'outils.
//
// Un rappel de loki (budget d'outils, relance) a le rôle `user` sans être une
// demande (isLokiInjected) : ni il ne prouve qu'une demande a été servie, ni
// il ne se réinjecte à la place de la vraie.
tailHasUser := false
for _, m := range msgs[tailStart:] {
if m.Role == "user" {
if m.Role == "user" && !isLokiInjected(m) {
tailHasUser = true
break
}
@@ -436,7 +510,7 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
summarized := err == nil && !summaryLooksEmpty(summary)
var pending []Message
for i := len(torso) - 1; i >= 0 && summarized && !tailHasUser; i-- {
if torso[i].Role != "user" {
if torso[i].Role != "user" || isLokiInjected(torso[i]) {
continue
}
if strings.HasPrefix(msgText(torso[i]), compactSummaryPrefix) {
@@ -557,6 +631,12 @@ func renderTranscript(msgs []Message) string {
for _, m := range msgs {
switch m.Role {
case "user":
// Rappel de loki : une note du système, pas une demande que le
// résumé devrait attribuer à l'utilisateur.
if isLokiInjected(m) {
fmt.Fprintf(&b, "System: %s\n", msgText(m))
continue
}
fmt.Fprintf(&b, "User: %s\n", msgText(m))
case "assistant":
if t := msgText(m); t != "" {
@@ -588,8 +668,10 @@ func renderTranscript(msgs []Message) string {
type summarizeResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
@@ -660,6 +742,9 @@ Write the summary in the SAME language as the conversation.`
}
req.Header.Set("Content-Type", "application/json")
ep.auth(req.Header.Set)
if !ep.External {
defer engineRequestStart()() // voir llm_slots.go
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", friendlyLLMError(err)
@@ -673,27 +758,93 @@ Write the summary in the SAME language as the conversation.`
}
return "", fmt.Errorf("résumé: %s %d: %s", who, resp.StatusCode, strings.TrimSpace(string(b)))
}
var out summarizeResp
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
raw, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
// Decoder et non Unmarshal : comme avant, seule la première valeur JSON
// compte — un octet parasite après elle (proxy, passerelle) ne doit pas
// faire échouer une compaction qui passait.
var out summarizeResp
if err := json.NewDecoder(bytes.NewReader(raw)).Decode(&out); err != nil {
return "", err
}
// Télémétrie de la compaction : réponse non streamée, timings et usage sont
// à la racine. Lue à part et sans échec possible (perfWire) : un compteur
// mal typé ne doit jamais faire échouer la compaction.
perfRecord(perfRecFromWire(perfTag{kind: perfCompact, conv: perfTagOf(ctx).conv}, decodePerfWire(raw)), nil)
if len(out.Choices) == 0 {
return "", fmt.Errorf("résumé: réponse vide")
}
c := out.Choices[0].Message.Content
// Certains modèles à raisonnement préfixent un bloc <think>…</think> : on ne
// garde que la réponse finale.
if i := strings.LastIndex(c, thinkClose); i >= 0 {
c = c[i+len(thinkClose):]
ch := out.Choices[0]
return cleanSummary(ch.Message.Content, ch.Message.ReasoningContent, ch.FinishReason)
}
// summaryRuneCap borne le résumé en caractères, en filet de sécurité
// seulement. L'ancienne borne fixe de 2200 caractères (≈ 550 tokens) coupait
// un résumé que max_tokens autorisait à ~1600 tokens — et le prompt met l'ÉTAT
// D'AVANCEMENT en dernier : c'était précisément lui, ce dont l'agent a besoin
// pour reprendre, qui tombait. max_tokens borne déjà la sortie ; ×6 caractères
// par token laisse passer tout résumé que le moteur a pu produire (le français
// tourne autour de 4) et ne mord que sur une API qui ignorerait max_tokens.
func summaryRuneCap() int {
return compactSummaryBudget() * 6
}
// cleanSummary extrait le texte final d'une réponse de résumé. Une erreur fait
// retomber l'appelant sur le torse dégraissé, sans perte : bien mieux que
// d'installer du raisonnement brut comme « mémoire » de la conversation.
// - un bloc <think>…</think> en tête est retiré, seule la réponse compte ;
// - un <think> ouvert en tête et jamais refermé (modèle qui ignore
// enable_thinking=false et bute sur max_tokens), ou un texte vide à côté
// d'un reasoning_content : il n'y a QUE du raisonnement, pas de résumé →
// erreur ;
// - finish_reason=length : le texte est gardé (le début d'un bon résumé vaut
// mieux que rien), marqué « […] » et tracé dans le journal.
func cleanSummary(content, reasoning, finish string) (string, error) {
c := strings.TrimSpace(content)
// Le raisonnement ne se retire qu'en TÊTE : soit un bloc <think>…</think>
// qui ouvre la réponse, soit un </think> sans <think> avant lui (gabarit qui
// ouvre le bloc dans le prompt). Couper au DERNIER </think>, comme avant,
// amputait tout le début d'un résumé de session Code qui cite la balise (un
// parseur, un gabarit) — et une balise citée après un <think> en milieu de
// texte n'est pas du raisonnement non plus.
for {
i := strings.Index(c, thinkClose)
if i < 0 {
break
}
if o := strings.Index(c[:i], "<think>"); o > 0 {
break
}
c = strings.TrimSpace(c[i+len(thinkClose):])
if !strings.HasPrefix(c, "<think>") {
break
}
}
c = strings.TrimSpace(c)
// Garde-fou dur : même si le modèle ignore la consigne de longueur, on tronque
// pour garantir une vraie compression. 2200 caractères (≈ 550 tokens) et pas
// 1500 : le résumé doit désormais porter les FAITS déjà trouvés, pas seulement
// l'intention, sinon l'IA repart en recherche après chaque compactage. Coupé
// sur une frontière de rune (é, … ne doivent pas devenir des �).
if r := []rune(c); len(r) > 2200 {
c = strings.TrimSpace(string(r[:2200])) + " […]"
if strings.HasPrefix(c, "<think>") {
return "", fmt.Errorf("résumé: raisonnement jamais refermé, aucun résumé")
}
if c == "" {
if strings.TrimSpace(reasoning) != "" || strings.TrimSpace(content) != "" {
return "", fmt.Errorf("résumé: rien hors du raisonnement")
}
return "", fmt.Errorf("résumé: texte vide")
}
cut := ""
if r := []rune(c); len(r) > summaryRuneCap() {
// Coupé sur une frontière de rune (é, … ne doivent pas devenir des �).
c = strings.TrimSpace(string(r[:summaryRuneCap()]))
cut = fmt.Sprintf("par le filet de %d caractères", summaryRuneCap())
}
if finish == "length" {
cut = fmt.Sprintf("par max_tokens=%d", compactSummaryBudget())
}
if cut != "" {
// Tracé dans les deux cas : le filet ne mord que sur une API qui
// ignore max_tokens, et c'est l'ÉTAT D'AVANCEMENT qui tombe.
fmt.Fprintf(os.Stderr, "[compact] résumé coupé %s (%d caractères gardés)\n", cut, len([]rune(c)))
c += " […]"
}
return c, nil
}
@@ -770,12 +921,26 @@ func shrinkToFit(msgs []Message, actual int) ([]Message, bool) {
head++
}
for estimateTokens(out) > target {
next := -1
// Coupe d'abord aux vraies demandes : couper sur un rappel de loki
// jetterait la demande en gardant la fin de sa boucle d'outils. Un
// rappel ne sert de coupe qu'en dernier recours, quand il n'y a plus
// aucune demande après la tête (une seule longue boucle d'outils) —
// comme avant qu'ils soient persistés.
next, nudge := -1, -1
for k := head + 1; k < len(out); k++ {
if out[k].Role == "user" {
if out[k].Role != "user" {
continue
}
if !isLokiInjected(out[k]) {
next = k
break
}
if nudge < 0 {
nudge = k
}
}
if next < 0 {
next = nudge
}
if next < 0 {
break
+77
View File
@@ -1,11 +1,13 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
)
@@ -252,3 +254,78 @@ func TestCompactageModeCodeGardeLEtat(t *testing.T) {
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)
}
}
+158 -59
View File
@@ -59,6 +59,14 @@ type Conversation struct {
Log []LogEvent `json:"log"` // vue « UI » rejouable
Seq int `json:"seq"`
CtxUsed int `json:"ctx_used"` // taille réelle du contexte au dernier tour
// ctxUsedLen : nombre de messages de Messages que CtxUsed couvre déjà (0 =
// inconnu). Ce qui arrive ensuite — le message utilisateur du tour suivant,
// la file — est estimé en plus par ctxPending : le moteur ne l'a pas encore
// compté. genPeak : génération la plus longue du dernier tour (llama-server
// local), pour la garde de marge de compactNeeded. Ni l'un ni l'autre n'est
// persisté : un rechargement repart du seul CtxUsed, comme avant.
ctxUsedLen int
genPeak int
Generating bool `json:"-"`
cancel context.CancelFunc // annule la génération en cours (/stop)
@@ -69,6 +77,9 @@ type Conversation struct {
// à une génération fantôme dans la discussion ouverte.
runningTaskID string
runningTaskName string
// benching : le verrou est tenu par un benchmark (llm_bench_job.go). Même
// logique que la tâche : l'interface et les refus disent qui occupe le moteur.
benching bool
// File d'attente des messages envoyés PENDANT une génération (AJEAN
// 0.14.0). Ils sont injectés dans le tour en cours à la prochaine frontière
@@ -183,20 +194,34 @@ func LoadConversation() {
// loadFrom remplace l'état en mémoire par une autre discussion (b vide = fil
// neuf) et invalide les abonnés : l'epoch incrémenté leur fait vider l'écran et
// rejouer depuis zéro, exactement comme un reset. L'appelant NE doit PAS
// détenir mu.
func (c *Conversation) loadFrom(b []byte) {
c.Stop()
// rejouer depuis zéro, exactement comme un reset. id = discussion chargée : le
// cache de la discussion active change sous mu, avec le contenu (voir
// convActivate). L'appelant NE doit PAS détenir mu.
func (c *Conversation) loadFrom(id string, b []byte) {
// Un benchmark n'appartient à aucune discussion : changer de fil ne l'arrête
// pas, et le verrou reste à lui jusqu'à sa fin (llm_bench_job.go).
c.mu.Lock()
bench := c.benching
c.mu.Unlock()
if !bench {
c.Stop()
}
c.mu.Lock()
if c == conv {
convActiveRemember(id)
}
c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0
c.ctxUsedLen, c.genPeak = 0, 0
c.queued = nil // file de l'ancienne discussion : elle ne suit pas la bascule
if len(b) > 0 {
_ = json.Unmarshal(b, c)
c.Messages = stripImageParts(c.Messages) // même guérison qu'au chargement
}
go pruneChatImages() // plus rien ne référence les images de l'ancien fil
c.Generating = false
c.cancel = nil
if !c.benching {
c.Generating = false
c.cancel = nil
}
c.epoch++
c.cond.Broadcast()
c.mu.Unlock()
@@ -215,34 +240,6 @@ func reloadEncryptedStores() {
}
}
// persist enregistre l'état (appelé en fin de tour et sur reset, pas à chaque
// delta) sous la discussion active, et rafraîchit ses métadonnées (titre déduit
// du premier message, date, nombre d'échanges). L'appelant NE doit PAS détenir mu.
func (c *Conversation) persist() {
c.mu.Lock()
// Images en base64 → références (chat_images.go) avant d'écrire : une photo
// jointe pesait des Mo, réécrits en entier à CHAQUE fin de tour.
refImagesInMessages(c.Messages)
b, err := json.Marshal(c)
title := convSummary(c.Messages)
turns := 0
for _, ev := range c.Log {
if _, ok := ev.Delta["user"]; ok {
turns++
}
}
c.mu.Unlock()
if err != nil {
return
}
id := convEnsureActive()
// Chiffré si la mémoire l'est et qu'elle est déverrouillée. Verrouillée,
// l'écriture est REFUSÉE plutôt que de remplacer un blob chiffré par du
// clair — le fil de ce tour reste en RAM, rien n'est perdu sur disque.
_ = putStoreBytes(bkChat, convKey(id), b)
convTouchMeta(id, title, turns)
}
// appendDelta journalise un événement d'affichage et réveille les abonnés.
// epoch est celui capturé au début du tour : si un Reset est passé entre-temps,
// l'événement appartient à l'ancienne conversation et est jeté (sinon il
@@ -294,8 +291,16 @@ func evTS0(d map[string]any, fallback int64) int64 {
// On préserve `toks` (somme) et `ts0` (premier) pour que le compteur de vitesse
// (tok/s) reste correct au replay. Verrou détenu par l'appelant.
func (c *Conversation) compactLogLocked() {
if len(c.Log) < 2 {
return
c.Log = compactLog(c.Log)
}
// compactLog est la compaction de compactLogLocked sous forme PURE : elle rend
// un nouveau journal sans toucher à celui qu'on lui passe (les événements non
// fusionnés sont partagés, pas recopiés). snapshot s'en sert pour écrire, en
// plein tour, la forme qu'aura le journal à la fin du tour.
func compactLog(log []LogEvent) []LogEvent {
if len(log) < 2 {
return log
}
textKey := func(d map[string]any) string {
if _, ok := d["content"].(string); ok {
@@ -306,7 +311,7 @@ func (c *Conversation) compactLogLocked() {
}
return ""
}
out := make([]LogEvent, 0, len(c.Log))
out := make([]LogEvent, 0, len(log))
var buf strings.Builder
bufKey := ""
var cur LogEvent
@@ -323,7 +328,7 @@ func (c *Conversation) compactLogLocked() {
buf.Reset()
bufKey, toks, ts0, seq0 = "", 0, 0, 0
}
for _, ev := range c.Log {
for _, ev := range log {
// Outils : un appel s'écrit en de nombreux événements (annonce done=false,
// frappe du corps, streaming des arguments) jusqu'au done=true, qui porte
// déjà l'état FINAL (résultat + diff). Les intermédiaires ne servent qu'à
@@ -359,7 +364,7 @@ func (c *Conversation) compactLogLocked() {
toks += evToks(ev.Delta)
}
flush()
c.Log = out
return out
}
// compactAndPublish exécute UNE compaction et en publie tout le cycle de vie :
@@ -401,6 +406,7 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
if c.epoch == epoch {
c.Messages = compacted
c.CtxUsed = est // le vrai compte reviendra avec les stats du prochain tour
c.ctxUsedLen = len(compacted)
}
c.mu.Unlock()
c.appendDelta(epoch, map[string]any{"compacted": true})
@@ -449,8 +455,11 @@ var ErrBusy = fmt.Errorf("génération en cours")
// nomme la tâche et on dit comment reprendre la main.
func (c *Conversation) busyReason() error {
c.mu.Lock()
name := c.runningTaskName
name, bench := c.runningTaskName, c.benching
c.mu.Unlock()
if bench {
return errBenchBusy
}
if name == "" {
return ErrBusy
}
@@ -501,9 +510,10 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
epoch := c.epoch
c.mu.Unlock()
// Borne de tour + bulle utilisateur (rejouables). Persistée tout de suite :
// si le process meurt en pleine génération (crash, restart après MAJ), le
// message de l'utilisateur survit au lieu de disparaître avec le tour.
// Borne de tour + bulle utilisateur (rejouables). Persistée tout de suite
// (sur disque quelques ms plus tard, voir persistAsync) : si le process
// meurt en pleine génération (crash, restart après MAJ), le message de
// l'utilisateur survit au lieu de disparaître avec le tour.
delta := map[string]any{"user": text}
// Nom du modèle qui va produire la réponse : journalisé avec la borne de
// tour, donc rejoué au chargement — la carte de réponse garde son modèle
@@ -521,7 +531,9 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
if !caps.Code {
maybeCodeHint(c, epoch, text)
}
c.persist()
// Instantané pris ici, écriture laissée à l'écrivain (chat_persist.go) : le
// fsync ne retarde plus le départ de la requête vers le modèle.
c.persistAsync()
if temperature == 0 {
temperature = 0.7
}
@@ -553,8 +565,8 @@ func (c *Conversation) seenCIDLocked(cid string) bool {
// EnqueueOrStart démarre un tour tout de suite si le moteur est libre, sinon
// MET EN FILE le message (AJEAN 0.14.0) — au lieu du 409 d'avant, qui obligeait
// à arrêter la réponse pour ajouter une précision. Seul un tour de CHAT accepte
// une file : une tâche planifiée qui occupe le modèle garde le refus
// (busyReason), sa fin ne dépile rien. cid = identifiant de l'envoi (voir
// une file : une tâche planifiée ou un benchmark qui occupe le modèle garde le
// refus (busyReason), sa fin ne dépile rien. cid = identifiant de l'envoi (voir
// recentCIDs) ; un doublon renvoie ErrDupSend.
func (c *Conversation) EnqueueOrStart(cid, text string, files []attachInfo, caps Caps, temperature float64) (bool, error) {
c.mu.Lock()
@@ -562,7 +574,7 @@ func (c *Conversation) EnqueueOrStart(cid, text string, files []attachInfo, caps
c.mu.Unlock()
return false, ErrDupSend
}
if c.Generating && c.runningTaskName == "" {
if c.Generating && c.runningTaskName == "" && !c.benching {
c.queued = append(c.queued, queuedMsg{text: text, files: files, caps: caps, temp: temperature})
c.mu.Unlock()
return true, nil
@@ -574,7 +586,7 @@ func (c *Conversation) EnqueueOrStart(cid, text string, files []attachInfo, caps
// appareils qui envoient en même temps) : en file plutôt qu'un refus
// (AJEAN 0.17.4). Une tâche planifiée garde le refus : sa fin ne dépile rien.
c.mu.Lock()
if c.Generating && c.runningTaskName == "" {
if c.Generating && c.runningTaskName == "" && !c.benching {
c.queued = append(c.queued, queuedMsg{text: text, files: files, caps: caps, temp: temperature})
c.mu.Unlock()
// Ce tour a pu finir entre-temps : sans relance, le message attendrait
@@ -738,20 +750,39 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(time.Since(turnStart)))
}
}()
// Télémétrie : tout ce que ce tour envoie au moteur (étapes, compaction,
// sous-agents, vérification) est rattaché à la discussion active.
ctx = withPerf(ctx, perfMain, convActiveID())
// llama-server local seulement : le preset externe garde le seul seuil, et
// ses complétions ne nourrissent pas la garde de marge (compactNeeded).
local := !externalActive()
// Snapshot de la vue modèle.
c.mu.Lock()
msgs := append([]Message(nil), c.Messages...)
ctxUsed := c.CtxUsed
// Dernier compte du moteur + ce qu'il n'a pas encore vu (ce message-ci, la
// file). Sans ce complément, seul le raisonnement compté à tort masquait le
// manque ; maintenant qu'il est retiré, le nouveau message doit compter.
ctxUsed := c.ctxNowLocked(msgs, local)
peak := c.genPeak
c.genPeak = 0 // recompté pendant ce tour
c.mu.Unlock()
// Compaction proactive (façon Hermes) sur la vue MODÈLE uniquement ; le journal
// d'affichage garde le fil complet. Le résumé est un appel modèle non streamé :
// il bloque plusieurs secondes AVANT que la vraie réponse commence, d'où la
// bannière de progression émise par compactAndPublish.
if compactWouldTrigger(msgs, ctxUsed) {
// Estimation de début de tour, comparée au premier compte réel du moteur
// (journal [ctx], seulement au-delà de 2 % d'écart).
startEst := ctxUsed
if compactNeeded(msgs, ctxUsed, peak) {
if out, changed := c.compactAndPublish(ctx, epoch, "début-tour", msgs, ctxUsed, caps); changed {
msgs = out
// Le chiffre d'avant compaction ne dit plus rien de la requête qui
// part : on compare celui que la compaction vient de poser.
c.mu.Lock()
startEst = c.CtxUsed
c.mu.Unlock()
}
}
@@ -788,10 +819,12 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// l'usage est arrivé ; sinon on retombe sur l'estimation (celle qui pilote
// déjà la compaction), approximative mais jamais absente.
sawUsage := false
turnPeak := 0
// asked : le tour s'est terminé sur une question à l'utilisateur (outil ask) —
// pas de vérification du mode Code avant sa réponse.
asked := false
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
sent, tools := prepareTurn(final, caps)
extra, _ := runChatTools(ctx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
switch {
case ev.Err != nil:
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
@@ -809,6 +842,10 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// ne l'ajoute que quand il est là pour ne pas gonfler chaque événement.
if ev.ToolUsed.Body != "" {
tu["body"] = ev.ToolUsed.Body
tu["body_lines"] = ev.ToolUsed.BodyLines
if ev.ToolUsed.BodyTail {
tu["body_tail"] = true
}
}
// Lignes +/- d'une écriture (edit / mémoire) : persistées avec le reste
// pour que le diff soit encore là après un rafraîchissement.
@@ -844,16 +881,23 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// compaction de début de tour.
c.appendDelta(epoch, map[string]any{"compacting": *ev.Compacting})
case ev.Stats != nil:
// Taille réelle du contexte (usage.prompt_tokens + généré) pour le compteur
// et la décision de compactage au tour suivant.
// Taille réelle du contexte (usage.prompt_tokens + généré, raisonnement
// non renvoyé retiré) pour le compteur et la décision de compactage au
// tour suivant.
if ev.Stats.PromptTokensTotal > 0 {
if !sawUsage && local && startEst > 0 {
logCtxEstimate("début-tour", startEst, ev.Stats.PromptTokensTotal)
}
sawUsage = true
c.mu.Lock()
if c.epoch == epoch {
c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
c.CtxUsed = ev.Stats.ctxAfter()
}
c.mu.Unlock()
}
if local {
turnPeak = max(turnPeak, ev.Stats.GenTokens)
}
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
case ev.Ask != nil:
// Question structurée (outil ask) : carte à boutons dans l'UI.
@@ -883,9 +927,19 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
if s := content.String(); strings.TrimSpace(s) != "" {
c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
}
// Rappel de loki resté sans réponse (stop, erreur) ou collé à un autre
// user : retiré, sinon deux `user` d'affilée au tour suivant.
c.Messages = dropStrayNudges(c.Messages)
// Le dernier compte couvre tout ce qui vient d'être rangé : la dernière
// requête portait le tour entier, sa génération en est la réponse.
if sawUsage {
c.ctxUsedLen = len(c.Messages)
}
c.genPeak = max(c.genPeak, turnPeak)
}
msgs = append([]Message(nil), c.Messages...)
ctxUsed = c.CtxUsed
ctxUsed = c.ctxNowLocked(msgs, local)
peak = c.genPeak
stale := c.epoch != epoch
c.mu.Unlock()
@@ -897,6 +951,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.mu.Lock()
if c.epoch == epoch {
c.CtxUsed = est
c.ctxUsedLen = len(msgs)
}
c.mu.Unlock()
c.appendDelta(epoch, map[string]any{"ctx_used": est})
@@ -910,7 +965,8 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.codeVerifyLoop(ctx, caps, temperature, epoch, asked)
c.mu.Lock()
msgs = append([]Message(nil), c.Messages...)
ctxUsed = c.CtxUsed
ctxUsed = c.ctxNowLocked(msgs, local)
peak = c.genPeak
stale = c.epoch != epoch
c.mu.Unlock()
}
@@ -924,7 +980,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// retombe tout de suite et le tour suivant démarre avec de la marge.
// Sauf si un Reset est passé (rien à compacter) ou si le tour a été annulé
// (bouton stop) : on n'enchaîne pas plusieurs secondes de résumé sur un stop.
if stale || ctx.Err() != nil || !compactWouldTrigger(msgs, ctxUsed) {
if stale || ctx.Err() != nil || !compactNeeded(msgs, ctxUsed, peak) {
return
}
// context.Background() et non ctx : le tour est terminé, son contexte peut
@@ -932,6 +988,18 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.compactAndPublish(context.Background(), epoch, "fin-tour", msgs, ctxUsed, caps)
}
// ctxNowLocked : contexte à juger pour compacter, c.mu tenu. llama-server
// local : le dernier compte plus les messages arrivés depuis (ctxPending). Preset
// externe : le dernier compte seul, comme avant — son CtxUsed garde encore le
// raisonnement (ctxAfter n'y retire rien), qui couvre déjà ce complément ; l'y
// ajouter le ferait compacter plus tôt qu'avant.
func (c *Conversation) ctxNowLocked(msgs []Message, local bool) int {
if !local {
return c.CtxUsed
}
return ctxPending(c.CtxUsed, c.ctxUsedLen, msgs)
}
// CompactNow force une compaction du contexte MAINTENANT, sans attendre le seuil
// (bouton « compacter » de l'UI). Détaché comme la génération : émet la bannière
// de progression, résume les anciens tours, remplace le torse et persiste. Les
@@ -941,6 +1009,7 @@ func (c *Conversation) CompactNow() error {
if !healthCheck() {
return errModelLoading
}
local := !externalActive()
c.mu.Lock()
if c.Generating {
c.mu.Unlock()
@@ -950,7 +1019,7 @@ func (c *Conversation) CompactNow() error {
ctx, cancel := context.WithCancel(context.Background())
c.cancel = cancel
msgs := append([]Message(nil), c.Messages...)
lastReal := c.CtxUsed // dernier contexte réel mesuré, pour estimer le surcoût fixe
lastReal := c.ctxNowLocked(msgs, local) // dernier contexte réel mesuré, pour estimer le surcoût fixe
epoch := c.epoch
c.mu.Unlock()
@@ -1006,8 +1075,10 @@ func (c *Conversation) Reset() {
c.Log = nil
c.Seq = 0
c.CtxUsed = 0
c.ctxUsedLen, c.genPeak = 0, 0
c.epoch++
c.Generating = false
c.benching = false // Stop a annulé le bench : le verrou est rendu ici
c.cancel = nil
c.queued = nil // les messages en attente visaient le fil qu'on vient de vider
c.cond.Broadcast()
@@ -1225,6 +1296,31 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
// Bloque jusqu'à ce que ctx (la connexion HTTP) soit annulé — la génération, elle,
// continue indépendamment. emit renvoie false si l'écriture échoue (client parti).
func (c *Conversation) SubscribeTail(ctx context.Context, from int, tail int, convID string, emit func(map[string]any) bool) {
c.subscribeSink(ctx, from, tail, convID, tailSink{emit: emit})
}
// tailSink : sortie d'un abonné. emit écrit et pousse aussitôt ; queue met en
// tampon (en écrivant d'office un tampon plein) et flush pousse ce qui attend.
// Sans queue/flush, tout passe par emit, un événement à la fois.
type tailSink struct {
emit func(map[string]any) bool
queue func(map[string]any) bool
flush func() bool
}
// subscribeSink : SubscribeTail, en regroupant le direct. Au réveil, plusieurs
// événements peuvent attendre (un jeton chacun) : une écriture et un flush par
// événement multipliaient les appels système et les trames d'un client en
// retard, ou d'un relais chiffré. Ils partent ensemble, dans l'ordre.
func (c *Conversation) subscribeSink(ctx context.Context, from int, tail int, convID string, sink tailSink) {
emit := sink.emit
queue, flush := sink.queue, sink.flush
if queue == nil {
queue = emit
}
if flush == nil {
flush = func() bool { return true }
}
// Réveille les attentes de cond quand la connexion se ferme.
go func() {
<-ctx.Done()
@@ -1384,10 +1480,13 @@ func (c *Conversation) SubscribeTail(ctx context.Context, from int, tail int, co
// `lastEmitted` (avant mise à jour) sert de repère : si la compaction de
// fin de tour vient de fusionner un bloc que le client suivait en direct,
// l'événement fusionné arrive avec un Seq supérieur au sien → `replace`.
if !emit(decorateEvent(ev, lastEmitted)) {
if !queue(decorateEvent(ev, lastEmitted)) {
return
}
}
if !flush() {
return
}
c.mu.Lock()
}
}
+2 -1
View File
@@ -221,7 +221,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++
+554
View File
@@ -0,0 +1,554 @@
package loki
// chat_persist.go — l'écriture de la discussion SORT du chemin du premier token.
//
// persist() faisait tout en ligne : marshal, puis putStoreBytes (ouverture de la
// base, commit, fsync), puis convTouchMeta (relecture de l'index, second commit,
// second fsync). Dans StartTurn, c'était AVANT `go c.generate` : 26-30 ms
// mesurés sur un NVMe Windows, bien davantage sur le /mnt/user d'Unraid (FUSE)
// ou un disque de parité — payés à chaque message avant que la requête ne parte
// vers llama-server. Même facture au milieu d'un tour (vérification du mode
// Code) et pour chaque résultat d'outil coupé (« voir plus »).
//
// Désormais :
// - l'INSTANTANÉ reste synchrone et pris sous c.mu (images → références,
// marshal, identifiant de la discussion) : rien ne peut le muter pendant
// qu'on le prend, et il dit toujours à quelle discussion il appartient ;
// - seule l'E/S bbolt part à un écrivain UNIQUE, qui fusionne ce qui s'est
// accumulé (dernier instantané de chaque discussion + résultats d'outils) en
// UNE transaction — un fsync au lieu de deux, ou de vingt sur un tour d'agent ;
// - seuls les chemins chauds sont asynchrones (StartTurn, persistance en cours
// de tour, résultats d'outils). Fin de tour, reset, bascules et suppression
// restent synchrones : ils attendent que l'écrivain ait tout vidé, eux
// compris. Aucun n'est sur le chemin du premier token, et un redémarrage
// juste après eux ne doit rien perdre.
//
// Ce qui atteint le modèle ne change pas d'un octet : il lit c.Messages en
// mémoire, et les résultats complets des outils ne servent qu'au « voir plus »
// de l'UI. Le risque accepté : un crash dans les millisecondes qui suivent un
// envoi peut perdre le message tout juste envoyé — le même que celui d'un crash
// en pleine génération, avant ce changement, pour la réponse.
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
bolt "go.etcd.io/bbolt"
)
// convSnap est un instantané complet d'une discussion, prêt à écrire.
type convSnap struct {
path string // base visée, figée à la prise (voir withDBAt)
id string // discussion à laquelle appartient le contenu
seq uint64 // ordre de prise : le plus grand l'emporte toujours
body []byte // JSON de Conversation
title string // titre déduit du premier message (convSummary)
turns int
project string // projet d'une entrée d'index à créer (voir convTouchMetaIn)
}
// toolResJob est un résultat d'outil complet à ranger dans bkToolRes.
type toolResJob struct {
path string
key string
plain string
}
// convPersister sérialise TOUTES les écritures de discussion. Un seul écrivain à
// la fois : l'ordre des instantanés ne dépend donc que de seq, jamais d'une
// course entre deux goroutines d'écriture.
type convPersister struct {
mu sync.Mutex
cond *sync.Cond
snaps map[string]*convSnap // dernier instantané en attente, par discussion
res []toolResJob // résultats d'outils en attente, dans l'ordre
mem map[string]string // résultats pas encore écrits : « voir plus » les sert d'ici
written map[string]uint64 // seq du dernier instantané écrit, par discussion
deleted map[string]bool // discussions supprimées : plus rien ne doit les réécrire
queued uint64 // tickets délivrés
done uint64 // tickets traités (écrits, abandonnés ou en échec)
running bool
}
var (
persistQ = newConvPersister()
// persistSeq numérote les instantanés. Incrémenté SOUS c.mu : l'ordre des
// numéros est donc celui des états de la discussion.
persistSeq atomic.Uint64
// persistCommit écrit un lot ; remplaçable par les tests pour observer
// l'ordre des écritures ou les ralentir.
persistCommit = commitPersistBatch
)
func newConvPersister() *convPersister {
p := &convPersister{
snaps: map[string]*convSnap{},
mem: map[string]string{},
written: map[string]uint64{},
deleted: map[string]bool{},
}
p.cond = sync.NewCond(&p.mu)
return p
}
// enqueueSnap confie un instantané à l'écrivain et renvoie son ticket. Un
// instantané en attente pour la même discussion est remplacé par le plus récent :
// écrire l'ancien ne servirait qu'à l'écraser aussitôt.
func (p *convPersister) enqueueSnap(s *convSnap) uint64 {
p.mu.Lock()
defer p.mu.Unlock()
if !p.deleted[s.id] {
if cur := p.snaps[s.id]; cur == nil || s.seq > cur.seq {
p.snaps[s.id] = s
}
}
return p.kickLocked()
}
// enqueueToolRes confie un résultat d'outil à l'écrivain. Il est servi depuis
// la mémoire tant qu'il n'est pas sur disque.
func (p *convPersister) enqueueToolRes(j toolResJob) uint64 {
p.mu.Lock()
defer p.mu.Unlock()
// Discussion déjà supprimée (un outil finit juste après la suppression) :
// l'écrivain l'écarterait, et la copie en mémoire ne partirait plus jamais.
if !p.deleted[toolResConv(j.key)] {
p.mem[j.key] = j.plain
p.res = append(p.res, j)
}
return p.kickLocked()
}
// kickLocked délivre un ticket et démarre l'écrivain s'il dort. p.mu détenu.
func (p *convPersister) kickLocked() uint64 {
p.queued++
if !p.running {
p.running = true
go p.run()
}
return p.queued
}
// run vide la file par lots, jusqu'à ce qu'elle soit vide.
func (p *convPersister) run() {
for {
p.mu.Lock()
if len(p.snaps) == 0 && len(p.res) == 0 {
p.running = false
p.done = p.queued
p.cond.Broadcast()
p.mu.Unlock()
return
}
upto := p.queued
snaps := make([]*convSnap, 0, len(p.snaps))
for id, s := range p.snaps {
if !p.deleted[id] && s.seq > p.written[id] {
snaps = append(snaps, s)
}
}
res := make([]toolResJob, 0, len(p.res))
for _, j := range p.res {
if !p.deleted[toolResConv(j.key)] {
res = append(res, j)
}
}
p.snaps, p.res = map[string]*convSnap{}, nil
p.mu.Unlock()
sort.Slice(snaps, func(i, j int) bool { return snaps[i].seq < snaps[j].seq })
wrote, err := persistCommitByPath(snaps, res)
p.mu.Lock()
for _, s := range wrote {
if s.seq > p.written[s.id] {
p.written[s.id] = s.seq
}
}
if err != nil {
// Les résultats restent servis depuis la mémoire : le « voir plus » de
// cette session marche encore, seul un redémarrage les perdrait. Un
// instantané raté sera remplacé par le suivant (fin de tour au plus tard).
fmt.Fprintf(os.Stderr, "[persist] écriture différée échouée (%d instantané(s), %d résultat(s) d'outil gardé(s) en mémoire) : %v\n", len(snaps)-len(wrote), len(res), err)
} else {
for _, j := range res {
if p.mem[j.key] == j.plain {
delete(p.mem, j.key)
}
}
}
p.done = upto
p.cond.Broadcast()
p.mu.Unlock()
}
}
// wait bloque jusqu'à ce que le ticket (et tous ceux d'avant) soit traité.
func (p *convPersister) wait(ticket uint64) {
p.mu.Lock()
for p.done < ticket {
p.cond.Wait()
}
p.mu.Unlock()
}
// flush attend que tout ce qui a été confié jusqu'ici soit traité.
func (p *convPersister) flush() {
p.mu.Lock()
t := p.queued
p.mu.Unlock()
p.wait(t)
}
// forget marque une discussion comme supprimée : ses instantanés et résultats en
// attente sont jetés, et plus rien ne la réécrira — sans ça, un instantané en
// vol la ferait renaître (contenu ET entrée d'index) juste après sa suppression.
func (p *convPersister) forget(id string) {
p.mu.Lock()
defer p.mu.Unlock()
p.deleted[id] = true
delete(p.snaps, id)
p.dropToolResLocked(id + ".")
}
// dropToolRes oublie les résultats en attente d'une discussion.
func (p *convPersister) dropToolRes(prefix string) {
p.mu.Lock()
defer p.mu.Unlock()
p.dropToolResLocked(prefix)
}
func (p *convPersister) dropToolResLocked(prefix string) {
for k := range p.mem {
if strings.HasPrefix(k, prefix) {
delete(p.mem, k)
}
}
kept := p.res[:0]
for _, j := range p.res {
if !strings.HasPrefix(j.key, prefix) {
kept = append(kept, j)
}
}
p.res = kept
}
// toolResPending relit un résultat pas encore sur disque.
func (p *convPersister) toolResPending(key string) (string, bool) {
p.mu.Lock()
defer p.mu.Unlock()
s, ok := p.mem[key]
return s, ok
}
// toolResConv extrait l'identifiant de discussion d'une clé « <id>.<aléa> ».
func toolResConv(key string) string {
if i := strings.LastIndexByte(key, '.'); i > 0 {
return key[:i]
}
return key
}
// persistExitWait borne l'attente de l'écrivain avant une sortie : le délai
// d'ouverture de la base (5 s, withDBAt) plus une marge.
const persistExitWait = 6 * time.Second
// flushPersister attend que les écritures différées soient sur disque, au plus
// d pour ne jamais retenir un arrêt sur une base bloquée. À appeler avant toute
// sortie volontaire du process (redémarrage, « Quitter », exec).
func flushPersister(d time.Duration) {
ok := make(chan struct{})
go func() {
persistQ.flush()
close(ok)
}()
select {
case <-ok:
case <-time.After(d):
}
}
// persistCommitByPath regroupe le lot par base (une seule en production) et
// l'écrit. Renvoie les instantanés effectivement écrits.
func persistCommitByPath(snaps []*convSnap, res []toolResJob) ([]*convSnap, error) {
paths := map[string]bool{}
for _, s := range snaps {
paths[s.path] = true
}
for _, j := range res {
paths[j.path] = true
}
var wrote []*convSnap
var firstErr error
for path := range paths {
var ps []*convSnap
var pr []toolResJob
for _, s := range snaps {
if s.path == path {
ps = append(ps, s)
}
}
for _, j := range res {
if j.path == path {
pr = append(pr, j)
}
}
w, err := persistCommit(path, ps, pr)
wrote = append(wrote, w...)
if err != nil && firstErr == nil {
firstErr = err
}
}
return wrote, firstErr
}
// commitPersistBatch écrit un lot en UNE transaction : les résultats d'outils,
// puis chaque instantané avec la mise à jour de son entrée d'index. Avant, le
// contenu et l'index étaient deux commits (deux fsync), et la relecture de
// l'index hors de tout verrou pouvait écraser un renommage concurrent.
//
// Mémoire chiffrée mais verrouillée : rien n'est écrit en clair, comme
// putStoreBytes — les instantanés sont sautés (le fil reste en RAM), les
// résultats d'outils renvoient une erreur et restent servis depuis la mémoire.
func commitPersistBatch(path string, snaps []*convSnap, res []toolResJob) ([]*convSnap, error) {
// Hors transaction : memEncActive relit la configuration, donc rouvrirait la
// base — bbolt n'est pas rentrant (voir memEncoderNow).
encode, encErr := memEncoderNow()
if len(res) > 0 && encErr != nil {
return nil, encErr
}
now := time.Now().Unix()
convIndexMu.Lock()
defer convIndexMu.Unlock()
cacheBust(bkChat)
cacheBust(bkToolRes)
var wrote []*convSnap
err := withDBAt(path, func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
wrote = wrote[:0]
if len(res) > 0 {
b, err := tx.CreateBucketIfNotExists([]byte(bkToolRes))
if err != nil {
return err
}
for _, j := range res {
enc, err := encode([]byte(j.plain))
if err != nil {
return err
}
if err := b.Put([]byte(j.key), enc); err != nil {
return err
}
}
}
if len(snaps) == 0 || encErr != nil {
return nil
}
b, err := tx.CreateBucketIfNotExists([]byte(bkChat))
if err != nil {
return err
}
// Index illisible (chiffré par une autre clé, abîmé) : on écrit le
// contenu mais on ne touche PAS à l'index — le réécrire depuis une
// lecture vide effacerait toutes les autres discussions de la liste.
var idx []convMeta
idxOK := true
if raw := b.Get([]byte(ckIndex)); len(raw) > 0 {
dec, err := decodeMemContent(raw)
if err != nil || json.Unmarshal(dec, &idx) != nil {
idxOK = false
}
}
for _, s := range snaps {
enc, err := encode(s.body)
if err != nil {
return err
}
if err := b.Put([]byte(convKey(s.id)), enc); err != nil {
return err
}
if idxOK {
idx = convTouchMetaIn(idx, s, now)
}
wrote = append(wrote, s)
}
if !idxOK {
return nil
}
ib, err := json.Marshal(idx)
if err != nil {
return err
}
enc, err := encode(ib)
if err != nil {
return err
}
return b.Put([]byte(ckIndex), enc)
})
})
if err != nil {
return nil, err
}
return wrote, nil
}
// convTouchMetaIn rafraîchit l'entrée d'index d'un instantané : date, nombre
// d'échanges, titre déduit si l'utilisateur n'en a pas choisi. Une discussion
// absente de l'index y est ajoutée — c'est ce qui recolle une discussion créée
// mémoire verrouillée (son entrée n'avait pas pu s'écrire). Une discussion
// SUPPRIMÉE n'arrive jamais ici : l'écrivain l'a écartée (forget).
func convTouchMetaIn(idx []convMeta, s *convSnap, now int64) []convMeta {
for i := range idx {
if idx[i].ID != s.id {
continue
}
idx[i].Updated = now
idx[i].Turns = s.turns
if idx[i].Title == "" {
idx[i].Title = s.title
}
return idx
}
return append(idx, convMeta{ID: s.id, Title: s.title, Created: now, Updated: now, Turns: s.turns, Project: s.project})
}
// --- Discussion active, gardée en RAM -----------------------------------------
//
// convEnsureActive relisait bkChat/active à CHAQUE appel : une ouverture de base
// (3-4 ms sur Windows) pour le mode Code, l'espace de travail, les résultats
// d'outils, la télémétrie… plusieurs fois par tour. Un seul process possède la
// conversation (relay_link.go) et toutes les bascules passent par
// convActivate : le cache ne peut donc pas diverger de la base, exactement
// comme activeProjCache pour le projet actif.
//
// Le cache sert aussi de LIEN entre le contenu en mémoire et son identifiant :
// convActivate le change sous c.mu, en même temps que le contenu (loadFrom). Un
// instantané pris sous c.mu lit donc toujours l'identifiant du contenu qu'il
// sérialise — jamais l'ancien fil sous le nouvel identifiant, même si une
// bascule s'intercale entre deux étapes de persist.
type convActiveRef struct{ path, id string }
var (
// convActiveMu sérialise la création de l'identifiant et les bascules.
// Ordre des verrous : convActiveMu, puis c.mu (loadFrom). Jamais l'inverse —
// d'où la lecture sans verrou du cache chaud.
convActiveMu sync.Mutex
convActiveCache atomic.Pointer[convActiveRef]
// convIndexMu sérialise les lectures-modifications-écritures de l'index :
// celles des opérations de discussion et celle de l'écrivain.
convIndexMu sync.Mutex
)
// convActiveCached renvoie l'identifiant en cache pour la base courante, ou "".
func convActiveCached() string {
if r := convActiveCache.Load(); r != nil && r.path == dbPath() {
return r.id
}
return ""
}
// convActiveRemember met l'identifiant en cache. Un pointeur encore chiffré par
// une ancienne version (healPlainStoreKeys) n'est pas mis en cache : il sera
// relu en clair après la réparation.
func convActiveRemember(id string) {
if id == "" || looksEncrypted([]byte(id)) {
convActiveCache.Store(nil)
return
}
convActiveCache.Store(&convActiveRef{path: dbPath(), id: id})
}
// convActiveID lit la discussion active SANS jamais en créer une : cache chaud,
// sinon la clé en base (vide si aucune). Pour les lecteurs qui, avant le cache,
// lisaient la clé brute — les résultats d'outils d'une tâche de fond sur une base
// neuve vont sous « nosession » au lieu de forger une discussion vide.
func convActiveID() string {
if id := convActiveCached(); id != "" {
return id
}
return getStr(bkChat, ckActive)
}
// convActivate fait de id la discussion active et charge b en mémoire, sous
// convActiveMu : le pointeur en base, le cache et le contenu changent ensemble.
func convActivate(id string, b []byte) {
convActiveMu.Lock()
defer convActiveMu.Unlock()
_ = putStr(bkChat, ckActive, id)
conv.loadFrom(id, b)
}
// snapshot prend l'instantané à écrire. L'appelant NE doit PAS détenir mu.
func (c *Conversation) snapshot() *convSnap {
// Hors verrou : au premier appel, convEnsureActive lit (voire crée) la clé
// en base. Ensuite le cache est chaud et relu sous c.mu.
id0 := convEnsureActive()
project := activeProjectSlug()
c.mu.Lock()
defer c.mu.Unlock()
id := id0
if c == conv {
if cid := convActiveCached(); cid != "" {
id = cid
}
}
// Images en base64 → références (chat_images.go) avant d'écrire : une photo
// jointe pesait des Mo, réécrits en entier à CHAQUE fin de tour.
refImagesInMessages(c.Messages)
// En plein tour, le journal porte encore un événement par token et chaque
// frappe d'outil : on écrit la forme que produira la fin de tour
// (compactLog), sans toucher au journal en mémoire que suivent les abonnés.
full := c.Log
if c.Generating {
c.Log = compactLog(full)
}
b, err := json.Marshal(c)
c.Log = full
if err != nil {
return nil
}
turns := 0
for _, ev := range full {
if _, ok := ev.Delta["user"]; ok {
turns++
}
}
return &convSnap{
path: dbPath(),
id: id,
seq: persistSeq.Add(1),
body: b,
title: convSummary(c.Messages),
turns: turns,
project: project,
}
}
// persist enregistre l'état sous sa discussion et ATTEND l'écriture : fin de
// tour, reset, bascules, compactage manuel. Tout ce qui était en attente avant
// lui est écrit aussi. L'appelant NE doit PAS détenir mu.
func (c *Conversation) persist() {
if s := c.snapshot(); s != nil {
persistQ.wait(persistQ.enqueueSnap(s))
return
}
persistQ.flush()
}
// persistAsync prend l'instantané tout de suite mais laisse l'écriture à
// l'écrivain : pour les chemins qui précèdent un appel au modèle (StartTurn,
// persistance en cours de tour). L'identifiant de la discussion est résolu ici,
// de façon synchrone — sur une base neuve, generate trouve donc la discussion
// active déjà créée, comme avant. L'appelant NE doit PAS détenir mu.
func (c *Conversation) persistAsync() {
if s := c.snapshot(); s != nil {
persistQ.enqueueSnap(s)
}
}
+459
View File
@@ -0,0 +1,459 @@
package loki
import (
"encoding/json"
"fmt"
"reflect"
"strings"
"sync"
"testing"
"time"
)
// gatePersist remplace l'écriture par une version qu'on retient : chaque lot
// signale son arrivée sur entered puis attend que release soit fermé. Les
// échanges de persistCommit se font écrivain vidé : aucun lot ne le lit alors.
func gatePersist(t *testing.T) (entered chan []*convSnap, release chan struct{}) {
t.Helper()
entered = make(chan []*convSnap, 64)
release = make(chan struct{})
persistQ.flush()
prev := persistCommit
persistCommit = func(path string, s []*convSnap, r []toolResJob) ([]*convSnap, error) {
entered <- s
<-release
return prev(path, s, r)
}
t.Cleanup(func() {
persistQ.flush()
persistCommit = prev
})
return entered, release
}
// waitEntered attend qu'un lot arrive à l'écriture.
func waitEntered(t *testing.T, entered chan []*convSnap) []*convSnap {
t.Helper()
select {
case s := <-entered:
return s
case <-time.After(5 * time.Second):
t.Fatal("aucun lot n'a atteint l'écriture")
return nil
}
}
// diskMessages relit le nombre de messages et le texte d'une discussion en base.
func diskMessages(t *testing.T, id string) (int, string) {
t.Helper()
b := storeBytes(bkChat, convKey(id))
if len(b) == 0 {
return -1, ""
}
var st struct {
Messages []Message `json:"messages"`
}
if err := json.Unmarshal(b, &st); err != nil {
t.Fatal(err)
}
return len(st.Messages), string(b)
}
func addUserMsg(text string) {
conv.mu.Lock()
conv.Messages = append(conv.Messages, Message{Role: "user", Content: text})
conv.Log = append(conv.Log, LogEvent{Seq: len(conv.Log) + 1, Delta: map[string]any{"user": text}})
conv.mu.Unlock()
}
// Des instantanés pris en rafale depuis plusieurs goroutines sont écrits dans
// l'ordre où ils ont été pris : jamais un plus ancien après un plus récent, et
// la base finit sur le dernier état.
func TestPersisterNEcritJamaisUnEtatPlusAncien(t *testing.T) {
testHome(t)
resetConvForTest()
persistQ.flush()
var mu sync.Mutex
var seqs []uint64
prev := persistCommit
persistCommit = func(path string, s []*convSnap, r []toolResJob) ([]*convSnap, error) {
mu.Lock()
for _, x := range s {
seqs = append(seqs, x.seq)
}
mu.Unlock()
time.Sleep(2 * time.Millisecond) // laisse la file s'accumuler
return prev(path, s, r)
}
t.Cleanup(func() {
persistQ.flush()
persistCommit = prev
})
const workers, per = 8, 10
var wg sync.WaitGroup
for g := 0; g < workers; g++ {
wg.Add(1)
go func(g int) {
defer wg.Done()
for i := 0; i < per; i++ {
addUserMsg(fmt.Sprintf("g%d-%d", g, i))
conv.persistAsync()
}
}(g)
}
wg.Wait()
persistQ.flush()
mu.Lock()
defer mu.Unlock()
for i := 1; i < len(seqs); i++ {
if seqs[i] <= seqs[i-1] {
t.Fatalf("instantané %d écrit après %d : un état plus ancien a écrasé un plus récent", seqs[i], seqs[i-1])
}
}
if n, _ := diskMessages(t, convEnsureActive()); n != workers*per {
t.Fatalf("la base finit sur %d messages, attendu %d (dernier état)", n, workers*per)
}
}
// persist (fin de tour, reset, bascule) est SYNCHRONE : il attend l'écriture en
// vol, puis la sienne. Au retour de Reset, la base porte déjà le fil vide.
func TestPersistSynchroneAttendLesEcrituresEnVol(t *testing.T) {
testHome(t)
resetConvForTest()
entered, release := gatePersist(t)
addUserMsg("avant le reset")
conv.persistAsync()
waitEntered(t, entered) // l'écrivain est retenu au milieu du lot
done := make(chan struct{})
go func() {
conv.Reset()
close(done)
}()
select {
case <-done:
t.Fatal("Reset est revenu avant que l'écriture en vol soit terminée")
case <-time.After(100 * time.Millisecond):
}
close(release)
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("Reset bloqué")
}
if n, _ := diskMessages(t, convEnsureActive()); n != 0 {
t.Fatalf("au retour de Reset, la base doit porter le fil vide (%d message(s))", n)
}
}
// « Voir plus » marche avant même que le résultat soit sur disque, et la copie
// en mémoire disparaît une fois l'écriture faite.
func TestToolResultServiAvantLEcriture(t *testing.T) {
testHome(t)
entered, release := gatePersist(t)
long := strings.Repeat("sortie\n", 1000)
id := saveToolResult(long)
if id == "" {
t.Fatal("résultat non enregistré")
}
waitEntered(t, entered)
if got, ok := loadToolResult(id); !ok || got != long {
t.Fatal("le résultat doit être servi depuis la mémoire tant qu'il n'est pas écrit")
}
close(release)
persistQ.flush()
if _, ok := persistQ.toolResPending(id); ok {
t.Fatal("la copie en mémoire doit partir une fois le résultat écrit")
}
if got, ok := loadToolResult(id); !ok || got != long {
t.Fatal("résultat perdu après l'écriture")
}
}
// Un résultat supprimé avec sa discussion avant d'être écrit n'est jamais écrit.
func TestToolResultSupprimeAvantEcriture(t *testing.T) {
testHome(t)
entered, release := gatePersist(t)
saveToolResult(strings.Repeat("a", 3000)) // retient l'écrivain
waitEntered(t, entered)
second := saveToolResult(strings.Repeat("b", 3000)) // en attente derrière
deleteToolResultsFor(toolResConv(second))
close(release)
persistQ.flush()
if _, ok := loadToolResult(second); ok {
t.Fatal("un résultat supprimé avant son écriture a été écrit quand même")
}
}
// compactLog rend exactement ce que donne la compaction de fin de tour, sans
// toucher au journal d'origine.
func TestCompactLogPurEgalFinDeTour(t *testing.T) {
log := []LogEvent{
{Seq: 1, TS: 10, Delta: map[string]any{"user": "q"}},
{Seq: 2, TS: 11, Delta: map[string]any{"reasoning_content": "je ", "toks": 1}},
{Seq: 3, TS: 12, Delta: map[string]any{"reasoning_content": "pense", "toks": 1}},
{Seq: 4, TS: 13, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "done": false}}},
{Seq: 5, TS: 14, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "done": true}}},
{Seq: 6, TS: 15, Delta: map[string]any{"content": "bon", "toks": 1}},
{Seq: 7, TS: 16, Delta: map[string]any{"content": "jour", "toks": 1}},
}
orig := append([]LogEvent(nil), log...)
got := compactLog(log)
if !reflect.DeepEqual(log, orig) {
t.Fatal("compactLog a modifié le journal d'origine")
}
c := newTestConv()
c.Log = append([]LogEvent(nil), log...)
c.compactLogLocked()
if !reflect.DeepEqual(got, c.Log) {
t.Fatalf("compactLog diffère de la fin de tour :\n%v\n%v", got, c.Log)
}
if len(got) != 4 {
t.Fatalf("attendu 4 événements (user, réflexion, outil fini, texte), got %d", len(got))
}
}
// En plein tour, la base reçoit le journal compacté ; le journal en mémoire
// (suivi par les abonnés) garde ses événements bruts.
func TestPersistEnPleinTourEcritLeJournalCompacte(t *testing.T) {
testHome(t)
resetConvForTest()
conv.mu.Lock()
conv.Generating = true
for i := 0; i < 50; i++ {
conv.Log = append(conv.Log, LogEvent{Seq: i + 1, Delta: map[string]any{"content": "x", "toks": 1}})
}
conv.mu.Unlock()
t.Cleanup(func() {
conv.mu.Lock()
conv.Generating = false
conv.mu.Unlock()
})
conv.persistAsync()
persistQ.flush()
var st struct {
Log []LogEvent `json:"log"`
}
if err := json.Unmarshal(storeBytes(bkChat, convKey(convEnsureActive())), &st); err != nil {
t.Fatal(err)
}
if len(st.Log) != 1 || st.Log[0].Delta["content"] != strings.Repeat("x", 50) {
t.Fatalf("journal écrit non compacté : %d événement(s)", len(st.Log))
}
conv.mu.Lock()
n := len(conv.Log)
conv.mu.Unlock()
if n != 50 {
t.Fatalf("le journal en mémoire ne doit pas être touché : %d événement(s)", n)
}
}
// Contenu et entrée d'index partent dans la MÊME écriture.
func TestPersistEcritContenuEtIndexEnUneFois(t *testing.T) {
testHome(t)
resetConvForTest()
persistQ.flush()
var calls int
var mu sync.Mutex
prev := persistCommit
persistCommit = func(path string, s []*convSnap, r []toolResJob) ([]*convSnap, error) {
mu.Lock()
calls++
mu.Unlock()
return prev(path, s, r)
}
t.Cleanup(func() {
persistQ.flush()
persistCommit = prev
})
id := convEnsureActive()
addUserMsg("titre de la discussion")
conv.persist()
mu.Lock()
n := calls
mu.Unlock()
if n != 1 {
t.Fatalf("%d écritures pour un persist, attendu 1", n)
}
if c, _ := diskMessages(t, id); c != 1 {
t.Fatalf("contenu non écrit (%d)", c)
}
for _, m := range convIndex() {
if m.ID == id {
if m.Turns != 1 || m.Title != "titre de la discussion" {
t.Fatalf("entrée d'index non rafraîchie : %+v", m)
}
return
}
}
t.Fatal("discussion absente de l'index")
}
// Supprimer une discussion pendant qu'une écriture la vise ne la fait pas
// renaître : ni contenu, ni entrée d'index.
func TestSuppressionPendantEcritureNeRessusciteRien(t *testing.T) {
testHome(t)
resetConvForTest()
a := convEnsureActive()
addUserMsg("fil A")
conv.persist()
b := convNew()
if err := convSwitch(a); err != nil {
t.Fatal(err)
}
entered, release := gatePersist(t)
addUserMsg("fil A, suite")
conv.persistAsync()
waitEntered(t, entered) // un lot visant A est en vol
addUserMsg("fil A, encore")
conv.persistAsync() // un autre attend derrière
done := make(chan error, 1)
go func() { done <- convDelete(a) }()
time.Sleep(50 * time.Millisecond)
close(release)
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("convDelete bloqué")
}
persistQ.flush()
if n, _ := diskMessages(t, a); n != -1 {
t.Fatalf("la discussion supprimée a été réécrite (%d messages)", n)
}
for _, m := range convIndex() {
if m.ID == a {
t.Fatal("la discussion supprimée est revenue dans l'index")
}
}
if got := convEnsureActive(); got != b {
t.Fatalf("active = %q, attendu %q", got, b)
}
}
// Basculer pendant qu'une écriture est en vol : l'ancien fil est écrit sous SON
// identifiant, jamais sous celui de la discussion qu'on ouvre.
func TestBasculePendantEcritureGardeChaqueFilChezLui(t *testing.T) {
testHome(t)
resetConvForTest()
a := convEnsureActive()
addUserMsg("seulement dans A")
conv.persist()
b := convNew()
addUserMsg("seulement dans B")
conv.persist()
if err := convSwitch(a); err != nil {
t.Fatal(err)
}
entered, release := gatePersist(t)
addUserMsg("A encore")
conv.persistAsync()
waitEntered(t, entered)
done := make(chan error, 1)
go func() { done <- convSwitch(b) }()
time.Sleep(50 * time.Millisecond)
close(release)
if err := <-done; err != nil {
t.Fatal(err)
}
persistQ.flush()
if _, body := diskMessages(t, b); strings.Contains(body, "dans A") {
t.Fatal("le fil A a été écrit sous l'identifiant de B")
}
if n, body := diskMessages(t, a); n != 2 || !strings.Contains(body, "A encore") {
t.Fatalf("le fil A n'a pas gardé son dernier état (%d messages)", n)
}
conv.mu.Lock()
got := len(conv.Messages)
conv.mu.Unlock()
if got != 1 {
t.Fatalf("la bascule doit charger B (1 message), got %d", got)
}
}
// Base neuve : l'instantané de StartTurn crée la discussion active TOUT DE
// SUITE, avant l'écriture — generate, qui démarre juste après, voit la même
// discussion (mode Code, critères, espace de travail) que la persistance.
func TestPersistAsyncCreeLaDiscussionActiveAvantGenerate(t *testing.T) {
testHome(t)
resetConvForTest()
entered, release := gatePersist(t)
defer close(release)
addUserMsg("premier message")
conv.persistAsync()
snaps := waitEntered(t, entered)
id := getStr(bkChat, ckActive)
if id == "" {
t.Fatal("la discussion active doit exister dès le retour de persistAsync")
}
if got := convEnsureActive(); got != id {
t.Fatalf("generate verrait %q, la persistance %q", got, id)
}
if len(snaps) != 1 || snaps[0].id != id {
t.Fatalf("l'instantané vise %v, attendu %q", snaps, id)
}
}
// Base neuve, appels simultanés : un seul identifiant est forgé.
func TestConvEnsureActiveConcurrentForgeUnSeulID(t *testing.T) {
testHome(t)
var wg sync.WaitGroup
ids := make([]string, 8)
for i := range ids {
wg.Add(1)
go func(i int) {
defer wg.Done()
ids[i] = convEnsureActive()
}(i)
}
wg.Wait()
for _, id := range ids[1:] {
if id != ids[0] {
t.Fatalf("identifiants divergents : %v", ids)
}
}
if n := len(convIndex()); n != 1 {
t.Fatalf("%d entrées d'index, attendu 1", n)
}
}
// Un outil qui finit juste après la suppression de sa discussion : son résultat
// n'est ni écrit ni gardé en mémoire pour toujours.
func TestToolResultApresSuppressionNeFuitPas(t *testing.T) {
testHome(t)
resetConvForTest()
a := convEnsureActive()
convNew()
persistQ.forget(a)
key := a + ".deadbeef"
persistQ.enqueueToolRes(toolResJob{path: dbPath(), key: key, plain: "orphelin"})
persistQ.flush()
if _, ok := persistQ.toolResPending(key); ok {
t.Fatal("résultat d'une discussion supprimée gardé en mémoire")
}
if b := getBytes(bkToolRes, key); b != nil {
t.Fatal("résultat d'une discussion supprimée écrit sur disque")
}
}
// Base neuve, sans discussion active : un résultat d'outil (tâche de fond) ne
// forge PAS de discussion vide dans la liste — il va sous « nosession », comme
// avant le cache.
func TestSaveToolResultNeCreePasDeDiscussion(t *testing.T) {
testHome(t)
id := saveToolResult(strings.Repeat("r", 3000))
if !strings.HasPrefix(id, "nosession.") {
t.Fatalf("id = %q, attendu nosession.*", id)
}
persistQ.flush()
if getStr(bkChat, ckActive) != "" || len(convIndex()) != 0 {
t.Fatal("saveToolResult a créé une discussion")
}
}
+8 -4
View File
@@ -49,9 +49,13 @@ import (
const promptCharBudget = 11000 // ~2750 tokens, tout allumé
func TestSystemPromptStaysLean(t *testing.T) {
// $LOKI_HOME isolé : les outils MCP configurés sur la machine de test (et
// leur ligne au préambule) ne comptent pas dans le budget de Loki.
testHome(t)
caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
sp := baseSystemPrompt(caps)
tb, err := json.Marshal(EnabledTools(caps))
tools := EnabledTools(caps)
sp := baseSystemPrompt(caps, tools)
tb, err := json.Marshal(tools)
if err != nil {
t.Fatal(err)
}
@@ -69,7 +73,7 @@ func TestSystemPromptStaysLean(t *testing.T) {
// Le prompt ne doit pas redevenir un catalogue d'outils : leurs schémas partent
// dans la MÊME requête et les décrivent déjà un par un.
func TestSystemPromptDoesNotRelistTools(t *testing.T) {
sp := baseSystemPrompt(Caps{Agent: true, Internet: true, Mem: MemAlways})
sp := baseSystemPrompt(Caps{Agent: true, Internet: true, Mem: MemAlways}, nil)
for _, line := range strings.Split(sp, "\n") {
l := strings.TrimSpace(line)
if !strings.HasPrefix(l, "- ") {
@@ -88,7 +92,7 @@ func TestSystemPromptDoesNotRelistTools(t *testing.T) {
// ordonne d'appeler des outils invente des appels textuels qui fuient dans la
// réponse.
func TestSystemPromptEmptyWithoutTools(t *testing.T) {
if sp := baseSystemPrompt(Caps{Mem: MemOff}); sp != "" {
if sp := baseSystemPrompt(Caps{Mem: MemOff}, nil); sp != "" {
t.Fatalf("préambule non vide sans outils : %q", sp)
}
}
+49 -34
View File
@@ -86,6 +86,8 @@ func convIndexForProject(slug string) []convMeta {
// tagOrphanConversations rattache au projet donné les discussions qui n'en ont
// pas encore (migration, une seule fois : ensuite il n'y a plus d'orphelines).
func tagOrphanConversations(slug string) {
convIndexMu.Lock()
defer convIndexMu.Unlock()
idx := convIndex()
changed := false
for i := range idx {
@@ -123,8 +125,22 @@ func convIndexSave(idx []convMeta) {
// convEnsureActive garantit qu'une discussion active existe, et reprend le fil
// unique de l'amont s'il y en a un (migration silencieuse, une seule fois).
//
// L'identifiant est gardé en RAM (chat_persist.go) : seule la première lecture
// ouvre la base. Le test, la lecture et la création se font sous convActiveMu —
// deux appelants simultanés sur une base neuve forgeaient sinon chacun leur
// identifiant, et le second rendait orpheline la discussion du premier.
func convEnsureActive() string {
if id := convActiveCached(); id != "" {
return id
}
convActiveMu.Lock()
defer convActiveMu.Unlock()
if id := convActiveCached(); id != "" {
return id
}
if id := getStr(bkChat, ckActive); id != "" {
convActiveRemember(id)
return id
}
id := newConvID()
@@ -135,7 +151,11 @@ func convEnsureActive() string {
}
_ = putStr(bkChat, ckActive, id)
now := time.Now().Unix()
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: activeProjectSlug()}))
project := activeProjectSlug()
convIndexMu.Lock()
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: project}))
convIndexMu.Unlock()
convActiveRemember(id)
return id
}
@@ -143,33 +163,12 @@ func convEnsureActive() string {
// dépendance à un générateur aléatoire.
func newConvID() string { return fmt.Sprintf("c%d", time.Now().UnixNano()) }
// convTouchMeta rafraîchit les métadonnées de la discussion active après un
// enregistrement : titre (déduit du premier message si l'utilisateur n'en a pas
// choisi), date de modification et nombre d'échanges.
func convTouchMeta(id string, title string, turns int) {
idx := convIndex()
now := time.Now().Unix()
for i := range idx {
if idx[i].ID != id {
continue
}
idx[i].Updated = now
idx[i].Turns = turns
if idx[i].Title == "" {
idx[i].Title = title
}
convIndexSave(idx)
return
}
convIndexSave(append(idx, convMeta{ID: id, Title: title, Created: now, Updated: now, Turns: turns, Project: activeProjectSlug()}))
}
// convSummary lit le premier message utilisateur pour en faire un titre. Sans
// message, on laisse vide : l'UI affiche « Nouvelle discussion » et le titre se
// posera tout seul au premier échange.
func convSummary(msgs []Message) string {
for _, m := range msgs {
if m.Role != "user" {
if m.Role != "user" || isLokiInjected(m) {
continue
}
s, _ := m.Content.(string)
@@ -217,8 +216,7 @@ func projectSwitch(slug string) error {
return nil
}
// convIndex rend la plus récemment modifiée en tête.
_ = putStr(bkChat, ckActive, list[0].ID)
conv.loadFrom(storeBytes(bkChat, convKey(list[0].ID)))
convActivate(list[0].ID, storeBytes(bkChat, convKey(list[0].ID)))
return nil
}
@@ -241,12 +239,11 @@ func convSwitch(id string) error {
if !found {
return fmt.Errorf("discussion introuvable")
}
if id == getStr(bkChat, ckActive) {
if id == convEnsureActive() {
return nil
}
conv.persist() // fige la discussion qu'on quitte
_ = putStr(bkChat, ckActive, id)
conv.loadFrom(storeBytes(bkChat, convKey(id)))
convActivate(id, storeBytes(bkChat, convKey(id)))
return nil
}
@@ -256,9 +253,11 @@ func convSwitch(id string) error {
func convCreate() string {
id := newConvID()
now := time.Now().Unix()
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: activeProjectSlug()}))
_ = putStr(bkChat, ckActive, id)
conv.loadFrom(nil)
project := activeProjectSlug()
convIndexMu.Lock()
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: project}))
convIndexMu.Unlock()
convActivate(id, nil)
return id
}
@@ -277,6 +276,8 @@ func convRename(id, title string) error {
if id == "" || title == "" {
return fmt.Errorf("identifiant ou titre manquant")
}
convIndexMu.Lock()
defer convIndexMu.Unlock()
idx := convIndex()
for i := range idx {
if idx[i].ID == id {
@@ -294,6 +295,7 @@ func convRename(id, title string) error {
func convDelete(id string) error {
convOpMu.Lock()
defer convOpMu.Unlock()
convIndexMu.Lock()
idx := convIndex()
next := make([]convMeta, 0, len(idx))
found := false
@@ -304,10 +306,24 @@ func convDelete(id string) error {
}
next = append(next, m)
}
convIndexMu.Unlock()
if !found {
return fmt.Errorf("discussion introuvable")
}
// Écritures différées (chat_persist.go) : on écarte d'abord tout ce qui
// viserait encore cette discussion, puis on attend l'écriture déjà en vol —
// sinon elle la ferait renaître, contenu et entrée d'index, juste après.
persistQ.forget(id)
persistQ.flush()
convIndexMu.Lock()
next = next[:0]
for _, m := range convIndex() {
if m.ID != id {
next = append(next, m)
}
}
convIndexSave(next)
convIndexMu.Unlock()
_ = putBytes(bkChat, convKey(id), nil)
// Mode code : les jobs d'arrière-plan de la discussion s'arrêtent avec
// elle, et ses critères/mode/puce partent avec ses messages.
@@ -319,14 +335,13 @@ func convDelete(id string) error {
// pour toujours, et plus aucun écran ne permettrait de les retrouver.
dropConvFiles(id)
deleteToolResultsFor(id) // ses résultats « voir plus » partent avec elle
if id != getStr(bkChat, ckActive) {
if id != convEnsureActive() {
return nil
}
if len(next) == 0 {
convCreate() // surtout pas convNew : il réenregistrerait la supprimée
return nil
}
_ = putStr(bkChat, ckActive, next[0].ID)
conv.loadFrom(storeBytes(bkChat, convKey(next[0].ID)))
convActivate(next[0].ID, storeBytes(bkChat, convKey(next[0].ID)))
return nil
}
+7 -4
View File
@@ -26,7 +26,10 @@ const (
// ("Wait, let me check… Wait, I'll just run it…") and commits to an action.
// The per-tool "Outil disponible" sections live in machine/skills prompts so
// they only appear when the matching feature is on.
func baseSystemPrompt(caps Caps) string {
//
// tools = les outils annoncés sur ce même tour (EnabledTools) : ce qui en est
// dit ici (la ligne MCP) en est déduit, jamais relu ailleurs.
func baseSystemPrompt(caps Caps, tools []Tool) string {
hasMem := caps.Mem != MemOff
// No tool access at all → no agentic preamble. A plain chat model told to
// "call tools immediately" hallucinates textual tool calls (e.g.
@@ -119,10 +122,10 @@ func baseSystemPrompt(caps Caps) string {
if caps.Agent && caps.ComputerUse {
b.WriteString(cuPromptLine())
}
// Ligne MCP tirée des outils de CE tour (voir mcpPromptLine) : un rôle sans
// outil MCP n'en reçoit pas, et le premier tour après un démarrage l'a déjà.
if caps.Agent {
if line := mcpPromptLine(); line != "" {
b.WriteString(line)
}
b.WriteString(mcpPromptLine(tools))
}
b.WriteString("\nDate: " + time.Now().Format("2006-01-02"))
return b.String()
+85 -7
View File
@@ -43,22 +43,27 @@ var defaultRetryPatterns = []string{
var (
retryOnce sync.Once
retryRes []*regexp.Regexp
// retryCustom : la surcharge `retry_patterns` est active. Ses motifs sont
// quelconques (\A, (?s).*? sur des Ko…) : le balayage fenêtré pendant le
// flux (textualToolCallFrom) ne vaut que pour les motifs embarqués.
retryCustom bool
)
func retryPatterns() []*regexp.Regexp {
retryOnce.Do(func() { retryRes = compileRetryPatterns() })
retryOnce.Do(func() { retryRes, retryCustom = compileRetryPatterns() })
return retryRes
}
func compileRetryPatterns() []*regexp.Regexp {
func compileRetryPatterns() ([]*regexp.Regexp, bool) {
pats := defaultRetryPatterns
custom := false
// Surcharge optionnelle : clé d'état `retry_patterns` = tableau JSON de
// regex. Pas d'UI dédiée — c'est un réglage d'expert, posé via
// `loki config` ou l'API ; l'embarqué couvre les cas connus.
if raw := getStr(bkState, "retry_patterns"); raw != "" {
var custom []string
if json.Unmarshal([]byte(raw), &custom) == nil && len(custom) > 0 {
pats = custom
var over []string
if json.Unmarshal([]byte(raw), &over) == nil && len(over) > 0 {
pats, custom = over, true
}
}
var out []*regexp.Regexp
@@ -67,7 +72,7 @@ func compileRetryPatterns() []*regexp.Regexp {
out = append(out, re)
}
}
return out
return out, custom
}
// textualToolCall dit si le texte final du tour contient un appel d'outil
@@ -76,6 +81,79 @@ func textualToolCall(content string) bool {
return textualToolCallSnippet(content) != ""
}
// retryScanMargin / retryScanMaxBack : marge relue avant la fin du balayage
// précédent, et recul au-delà duquel on retombe sur le balayage complet.
const (
retryScanMargin = 512
retryScanMaxBack = 16 << 10
)
// textualToolCallFrom : textualToolCall pendant le flux, quand content[:scanned]
// a DÉJÀ été balayé sans rien trouver. Relire toute la réponse à chaque morceau
// coûtait jusqu'à 360 µs par jeton sur 80 Ko — du CPU volé au décodage quand
// des experts tournent sur le processeur. Une correspondance neuve finit
// forcément après scanned : on ne relit que la fin, à partir d'un vrai début de
// ligne (sinon `^` verrait un début de ligne au milieu d'une phrase et
// relancerait un tour parfaitement bon). Motifs de la surcharge : balayage
// complet, rien ne borne leur portée.
func textualToolCallFrom(content string, scanned int) bool {
if content == "" {
return false
}
res := retryPatterns()
start := 0
if !retryCustom {
start = retryScanStart(content, scanned)
}
w := content[start:]
for _, re := range res {
if re.MatchString(w) {
return true
}
}
return false
}
// retryScanStart : début de la fenêtre à relire. On part de scanned − marge,
// puis on recule tant qu'on est dans une suite que les motifs embarqués peuvent
// traverser sur toute sa longueur (\w+, \s*, `{"name":`, multioctet) : un
// `<function=` suivi de 600 lettres, ou un `{"name":` noyé dans les blancs,
// reste donc entier dans la fenêtre. Enfin, début de la ligne. Recul trop long
// ou ligne sans fin : 0, le balayage complet d'avant.
func retryScanStart(content string, scanned int) int {
if scanned > len(content) {
scanned = len(content)
}
p := scanned - retryScanMargin
if p <= 0 {
return 0
}
floor := scanned - retryScanMaxBack
for p > 0 && retryRunByte(content[p-1]) {
p--
if p < floor {
return 0
}
}
i := strings.LastIndexByte(content[:p], '\n')
if i < 0 || i+1 < floor {
return 0
}
return i + 1
}
func retryRunByte(c byte) bool {
switch {
case c >= 0x80, c == '_', c >= '0' && c <= '9', c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z':
return true
}
switch c {
case ' ', '\t', '\n', '\r', '\f', '"', ':', '{':
return true
}
return false
}
// textualToolCallSnippet renvoie l'extrait fautif (le motif reconnu et un peu
// de ce qui le suit), "" si aucun. Cité dans la consigne corrective : le
// modèle voit CE qu'il a mal écrit au lieu d'une remontrance abstraite.
@@ -106,7 +184,7 @@ const maxPatternRetries = 3
// retryCorrective : le message réinjecté pour relancer le tour, avec l'extrait
// fautif.
func retryCorrective(snippet string) string {
return "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed:\n" + snippet +
return retryCorrectiveLead + snippet +
"\nTool calls must go through the tool-call protocol, never in the answer text. " +
"Redo it now: emit the real tool call, or answer directly without pretending to call a tool."
}
+5 -1
View File
@@ -103,13 +103,17 @@ func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string
{Role: "user", Content: task + framing},
}
var out strings.Builder
// Le sous-agent occupe le slot de la conversation du builder : une fois son
// rapport rendu, on l'efface pour que le builder reparte du cache RAM
// (llm_slots.go). Pas entre ses lectures : seulement à la fin.
defer engineSideJob()()
// Trace jetable : aucun forwardStream, rien de persisté. L'utilisateur voit
// la bulle de l'outil `subagent` et son rapport, pas les dix lectures.
// Température 0.6 et non 0 : en glouton, Qwen avec réflexion tourne vite en
// boucle (OpenFox l'a relevé). Le TEMP du preset, s'il est posé, l'emporte.
// Seul le texte écrit APRÈS le dernier outil revient au builder : la
// narration entre deux lectures n'est pas le rapport.
if _, err := runChat(ctx, msgs, subagentTemperature, caps, func(ev StreamEvent) bool {
if _, err := runChat(withPerfKind(ctx, perfSubagent), msgs, subagentTemperature, caps, func(ev StreamEvent) bool {
if ev.ToolUsed != nil {
out.Reset()
}
+46 -14
View File
@@ -112,9 +112,11 @@ func (c *Conversation) runVerifyPass(ctx context.Context, caps Caps, temperature
{Role: "user", Content: user.String()},
}
// Trace ISOLÉE : ni persistée dans c.Messages, ni compactée — elle vit et
// meurt dans cette passe. Seuls les deltas d'affichage sortent.
_, _ = runChat(ctx, msgs, temperature, vcaps, func(ev StreamEvent) bool {
c.forwardStream(ev, epoch)
// meurt dans cette passe. Seuls les deltas d'affichage sortent. Son état
// dans le moteur aussi : effacé à la fin de la passe (llm_slots.go).
defer engineSideJob()()
_, _ = runChat(withPerfKind(ctx, perfVerify), msgs, temperature, vcaps, func(ev StreamEvent) bool {
c.forwardStream(ev, epoch, true)
return true
})
}
@@ -163,7 +165,12 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
final = append([]Message{{Role: "system", Content: sp}}, final...)
}
var content strings.Builder
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
sawUsage := false
sent, tools := prepareTurn(final, caps)
extra, _ := runChatTools(ctx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
if ev.Stats != nil && ev.Stats.PromptTokensTotal > 0 {
sawUsage = true
}
if ev.Content != "" {
content.WriteString(ev.Content)
}
@@ -173,7 +180,7 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
if ev.Ask != nil {
asked = true
}
c.forwardStream(ev, epoch)
c.forwardStream(ev, epoch, false)
return true
})
c.mu.Lock()
@@ -182,16 +189,28 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
if s := content.String(); strings.TrimSpace(s) != "" {
c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
}
c.Messages = dropStrayNudges(c.Messages) // même règle que generate
if sawUsage {
c.ctxUsedLen = len(c.Messages) // même règle que generate
}
}
c.mu.Unlock()
c.persist()
// En plein tour : l'étape suivante part vers le modèle juste après, le fsync
// ne doit pas l'attendre (chat_persist.go).
c.persistAsync()
return asked
}
// forwardStream relaie les événements d'un runChat secondaire (vérification,
// correction) vers le journal d'affichage — même mapping que generate(), sans
// la gestion de compaction (ces passes n'en déclenchent pas : contexte court).
func (c *Conversation) forwardStream(ev StreamEvent, epoch int) {
//
// isolated : la passe tourne sur sa propre trace (vérificateur), PAS sur
// l'historique de la discussion. Ses comptes ne disent rien du contexte de
// celle-ci : ils écrasaient CtxUsed avec la petite taille du vérificateur, et
// le contrôle de fin de tour — puis celui du tour suivant — décidaient sur ce
// chiffre-là. La jauge de l'UI ne doit pas bouger non plus (ctx_isolated).
func (c *Conversation) forwardStream(ev StreamEvent, epoch int, isolated bool) {
switch {
case ev.Err != nil:
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
@@ -202,6 +221,10 @@ func (c *Conversation) forwardStream(ev StreamEvent, epoch int) {
}
if ev.ToolUsed.Body != "" {
tu["body"] = ev.ToolUsed.Body
tu["body_lines"] = ev.ToolUsed.BodyLines
if ev.ToolUsed.BodyTail {
tu["body_tail"] = true
}
}
if len(ev.ToolUsed.Diff) > 0 {
tu["diff"] = ev.ToolUsed.Diff
@@ -221,13 +244,21 @@ func (c *Conversation) forwardStream(ev StreamEvent, epoch int) {
case ev.Ask != nil:
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
case ev.Stats != nil:
if ev.Stats.PromptTokensTotal > 0 {
c.mu.Lock()
if c.epoch == epoch {
c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
}
c.mu.Unlock()
if isolated {
c.appendDelta(epoch, map[string]any{"stats": ev.Stats, "ctx_isolated": true})
break
}
local := !externalActive()
c.mu.Lock()
if c.epoch == epoch {
if ev.Stats.PromptTokensTotal > 0 {
c.CtxUsed = ev.Stats.ctxAfter()
}
if local {
c.genPeak = max(c.genPeak, ev.Stats.GenTokens)
}
}
c.mu.Unlock()
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
case ev.DropReasoning:
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
@@ -243,7 +274,8 @@ func (c *Conversation) lastUserText() string {
c.mu.Lock()
defer c.mu.Unlock()
for i := len(c.Messages) - 1; i >= 0; i-- {
if c.Messages[i].Role != "user" {
// Un rappel de loki (budget, relance) n'est pas la tâche.
if c.Messages[i].Role != "user" || isLokiInjected(c.Messages[i]) {
continue
}
if s, ok := c.Messages[i].Content.(string); ok {
+317
View File
@@ -0,0 +1,317 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
)
// Le contexte de la requête suivante = prompt + généré − raisonnement (jamais
// renvoyé), sans jamais retirer plus que ce qui a été généré.
func TestStatsCtxAfter(t *testing.T) {
cases := []struct {
name string
prompt, gen, reasonN int
want int
}{
{"sans raisonnement", 40000, 500, 0, 40500},
{"raisonnement retiré", 40000, 6000, 5000, 41000},
{"tout est raisonnement", 40000, 3000, 3000, 40000},
{"compte de morceaux au-delà du généré : borné", 40000, 2000, 2500, 40000},
{"pas d'usage", 0, 300, 100, 200},
}
for _, c := range cases {
s := StatsEvent{PromptTokensTotal: c.prompt, GenTokens: c.gen, ReasoningTokens: c.reasonN}
if got := s.ctxAfter(); got != c.want {
t.Errorf("%s : ctxAfter = %d, attendu %d", c.name, got, c.want)
}
}
}
// Garde de marge : à 65 k comme à 131 k, plancher et marge seuls ne devancent
// jamais le seuil de 75 % ; seule une génération récente longue l'avance.
func TestGenHeadroomShort(t *testing.T) {
cases := []struct {
name string
used, peak, window int
want bool
ratioAlreadyTriggers bool
}{
{"65k, inconnu", 60000, 0, 65536, false, true},
{"65k, courte : sous le seuil, pas d'avance", 49000, 2000, 65536, false, false},
{"65k, courte : plancher 8k + marge, sous 84 %", 55000, 2000, 65536, false, true},
{"65k, longue : 1,2×15k ne tient plus", 47000, 15000, 65536, true, false},
{"65k, longue mais la place y est", 40000, 15000, 65536, false, false},
{"65k, énorme (45k) mais historique tout juste compacté", 20000, 45000, 65536, false, false},
{"65k, énorme (45k) au-delà de la moitié", 33000, 45000, 65536, true, false},
{"131k, courte : rien avant le seuil", 98000, 4000, 131072, false, false},
{"131k, très longue génération", 95000, 30000, 131072, true, false},
{"8k, plancher ramené à 1/8", 6000, 0, 8192, false, false},
{"contexte inconnu", 0, 30000, 65536, false, false},
}
for _, c := range cases {
if got := genHeadroomShort(c.used, c.peak, c.window); got != c.want {
t.Errorf("%s : genHeadroomShort = %v, attendu %v", c.name, got, c.want)
}
ratio := c.used >= int(float64(c.window)*compactTriggerFrac)
if ratio != c.ratioAlreadyTriggers {
t.Errorf("%s : cas mal posé, seuil de 75 %% = %v", c.name, ratio)
}
}
// Sans génération longue, la garde ne part jamais avant le seuil de 75 %.
for _, w := range []int{4096, 8192, 16384, 32768, 65536, 131072, 262144} {
below := int(float64(w)*compactTriggerFrac) - 1
if genHeadroomShort(below, 1, w) {
t.Errorf("fenêtre %d : la garde devance le seuil sans génération longue", w)
}
// Génération aussi longue que la fenêtre : jamais avant la moitié, sinon
// l'historique fraîchement compacté la redéclenche à chaque étape.
if genHeadroomShort(w/2, w, w) {
t.Errorf("fenêtre %d : la garde part à 50 %% (compaction en boucle)", w)
}
if !genHeadroomShort(w/2+1, w, w) {
t.Errorf("fenêtre %d : la garde ne part pas au-delà de 50 %% avec une génération énorme", w)
}
}
}
// Le message arrivé depuis la dernière mesure s'ajoute ; une mesure inconnue
// ou qui ne correspond plus à l'historique n'ajoute rien.
func TestCtxPending(t *testing.T) {
msgs := []Message{um("bonjour"), am("salut"), um(strings.Repeat("x", 4000))}
added := estimateTokens(msgs[2:])
cases := []struct {
name string
used, measured int
want int
}{
{"nouveau message estimé", 30000, 2, 30000 + added},
{"rien de nouveau", 30000, 3, 30000},
{"mesure inconnue (rechargement)", 30000, 0, 30000},
{"historique raccourci depuis", 30000, 5, 30000},
{"aucun compte : repli sur l'estimation complète, ailleurs", 0, 2, 0},
}
for _, c := range cases {
if got := ctxPending(c.used, c.measured, msgs); got != c.want {
t.Errorf("%s : ctxPending = %d, attendu %d", c.name, got, c.want)
}
}
}
// fauxMoteurScript : llama-server de comédie qui sert ses réponses streamées
// dans l'ordre (la dernière se répète), et un résumé JSON aux appels non
// streamés (compaction). Garde le dernier message de chaque requête streamée.
func fauxMoteurScript(t *testing.T, streams ...string) (port string, lastMsgs func() []string) {
t.Helper()
var mu sync.Mutex
var last []string
n := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Stream bool `json:"stream"`
Messages []Message `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if !body.Stream {
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé des étapes précédentes"}}}})
return
}
mu.Lock()
i := min(n, len(streams)-1)
n++
if len(body.Messages) > 0 {
last = append(last, msgText(body.Messages[len(body.Messages)-1]))
}
mu.Unlock()
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
_, _ = w.Write([]byte(streams[i] + "data: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
return u.Port(), func() []string {
mu.Lock()
defer mu.Unlock()
return append([]string(nil), last...)
}
}
func sseReasoning(text string) string {
return `data: {"choices":[{"delta":{"reasoning_content":"` + text + `"}}]}` + "\n\n"
}
func sseFinal(finish string, prompt, gen int) string {
return `data: {"choices":[{"delta":{},"finish_reason":"` + finish + `"}]}` + "\n\n" +
`data: {"choices":[],"timings":{"prompt_n":10,"predicted_n":` + itoa(gen) + `},"usage":{"prompt_tokens":` + itoa(prompt) + `,"completion_tokens":` + itoa(gen) + `}}` + "\n\n"
}
// runForStats lance runChat et rend le dernier StatsEvent reçu.
func runForStats(t *testing.T, msgs []Message, caps Caps) (StatsEvent, string) {
t.Helper()
var st StatsEvent
var content strings.Builder
if _, err := runChat(context.Background(), msgs, 0.7, caps, func(ev StreamEvent) bool {
if ev.Stats != nil {
st = *ev.Stats
}
content.WriteString(ev.Content)
return true
}); err != nil {
t.Fatal(err)
}
return st, content.String()
}
// Raisonnement séparé par le moteur : compté, et retiré du contexte suivant.
func TestRaisonnementSepareCompte(t *testing.T) {
testHome(t)
port, _ := fauxMoteurScript(t, sseReasoning("je ")+sseReasoning("réfléchis ")+sseReasoning("ici")+
sseChunk("Bon")+sseChunk("jour")+sseFinal("stop", 100, 7))
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
st, _ := runForStats(t, []Message{um("salut")}, Caps{})
if st.ReasoningTokens != 3 {
t.Fatalf("ReasoningTokens = %d, attendu 3", st.ReasoningTokens)
}
if got := st.ctxAfter(); got != 104 {
t.Fatalf("ctxAfter = %d, attendu 104 (100 + 7 − 3)", got)
}
}
// <think> en ligne découpé chez nous : ce texte reste dans le message renvoyé
// au modèle pendant le tour, il ne doit donc PAS être retiré du compte.
func TestRaisonnementEnLigneNonCompte(t *testing.T) {
testHome(t)
port, _ := fauxMoteurScript(t, sseChunk("je pense")+sseChunk("</think>")+sseChunk("Réponse")+sseFinal("stop", 100, 6))
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
if err := SetConfigKey("REASONING", "on"); err != nil {
t.Fatal(err)
}
var sawReasoning bool
var st StatsEvent
if _, err := runChat(context.Background(), []Message{um("salut")}, 0.7, Caps{}, func(ev StreamEvent) bool {
if ev.Reasoning != "" {
sawReasoning = true
}
if ev.Stats != nil {
st = *ev.Stats
}
return true
}); err != nil {
t.Fatal(err)
}
if !sawReasoning {
t.Fatal("le <think> en ligne n'a pas été découpé : test mal posé")
}
if st.ReasoningTokens != 0 || st.ctxAfter() != 106 {
t.Fatalf("raisonnement en ligne compté : ReasoningTokens=%d ctxAfter=%d", st.ReasoningTokens, st.ctxAfter())
}
}
// Une relance (ici le nudge « pensé sans agir ») repart d'un compte vierge :
// le raisonnement d'une tentative jetée n'est pas retiré deux fois.
func TestRaisonnementRemisAZeroALaRelance(t *testing.T) {
withWorkspace(t)
port, _ := fauxMoteurScript(t,
sseReasoning("a")+sseReasoning("b")+sseReasoning("c")+sseFinal("stop", 100, 3),
sseReasoning("d")+sseChunk("Fait.")+sseFinal("stop", 120, 4))
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
st, content := runForStats(t, []Message{um("fais-le")}, Caps{Agent: true})
if content != "Fait." {
t.Fatalf("réponse = %q, la relance n'a pas eu lieu", content)
}
if st.ReasoningTokens != 1 || st.ctxAfter() != 123 {
t.Fatalf("après relance : ReasoningTokens=%d ctxAfter=%d, attendu 1 et 123", st.ReasoningTokens, st.ctxAfter())
}
}
// Fenêtre pleine en plein raisonnement (« length ») : on compacte et on rejoue
// l'étape, sans le nudge qui demanderait au modèle d'arrêter de raisonner.
func TestFenetrePleineCompacteEtRejoue(t *testing.T) {
withWorkspace(t)
if err := SetConfigKey("CTX", "4096"); err != nil {
t.Fatal(err)
}
port, lastMsgs := fauxMoteurScript(t,
sseReasoning("long")+sseReasoning(" raisonnement")+sseFinal("length", 3900, 196),
sseChunk("Réponse.")+sseFinal("stop", 1500, 3))
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
msgs := []Message{um("corrige le build"), am("ok"), um("continue")}
for i := 0; i < 10; i++ {
msgs = append(msgs, atc("bash"), tm("sortie "+strings.Repeat("x", 400)))
}
msgs = append(msgs, um("et maintenant ?"))
var compacted, dropped bool
var content strings.Builder
if _, err := runChat(context.Background(), msgs, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
if ev.NewHistory != nil {
compacted = true
}
if ev.DropReasoning {
dropped = true
}
content.WriteString(ev.Content)
return true
}); err != nil {
t.Fatal(err)
}
if !compacted || !dropped {
t.Fatalf("fenêtre pleine : compacté=%v raisonnement retiré=%v", compacted, dropped)
}
if content.String() != "Réponse." {
t.Fatalf("réponse = %q", content.String())
}
for _, m := range lastMsgs() {
if strings.Contains(m, "You reasoned but did not") || strings.Contains(m, "Stop reasoning") {
t.Fatalf("nudge envoyé malgré la fenêtre pleine : %q", m)
}
}
}
// La passe du vérificateur tourne sur sa propre trace : ses comptes ne
// touchent ni CtxUsed ni la jauge, contrairement à une passe de correction.
func TestPasseIsoleeNeTouchePasLeContexte(t *testing.T) {
testHome(t)
c := newTestConv()
c.CtxUsed = 45000
st := &StatsEvent{PromptTokensTotal: 3000, GenTokens: 800, ReasoningTokens: 500}
c.forwardStream(StreamEvent{Stats: st}, c.epoch, true)
if c.CtxUsed != 45000 {
t.Fatalf("passe isolée : CtxUsed = %d, attendu 45000 inchangé", c.CtxUsed)
}
if last := c.Log[len(c.Log)-1].Delta; last["ctx_isolated"] != true {
t.Fatalf("passe isolée : la jauge n'est pas prévenue (%v)", last)
}
c.forwardStream(StreamEvent{Stats: st}, c.epoch, false)
if c.CtxUsed != 3300 {
t.Fatalf("passe de correction : CtxUsed = %d, attendu 3300 (3000 + 800 − 500)", c.CtxUsed)
}
if c.genPeak != 800 {
t.Fatalf("passe de correction : genPeak = %d, attendu 800", c.genPeak)
}
}
// Preset externe : le compte mesuré seul, comme avant ; local : complété des
// messages arrivés depuis la mesure.
func TestCtxNowLockedExternal(t *testing.T) {
msgs := []Message{um("bonjour"), am("salut"), um(strings.Repeat("x", 4000))}
c := &Conversation{CtxUsed: 30000, ctxUsedLen: 2}
if got := c.ctxNowLocked(msgs, false); got != 30000 {
t.Errorf("externe : %d, attendu le compte mesuré seul (30000)", got)
}
if got, want := c.ctxNowLocked(msgs, true), 30000+estimateTokens(msgs[2:]); got != want {
t.Errorf("local : %d, attendu %d", got, want)
}
}
+920 -116
View File
File diff suppressed because it is too large. Load diff
+238
View File
@@ -0,0 +1,238 @@
package loki
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
// Le benchmark tourne en TÂCHE DE FOND. Le mode complet dure de une à cinq
// minutes : une requête HTTP synchrone était coupée bien avant par un reverse
// proxy (nginx d'Unraid à 60 s, Cloudflare à 100 s) ou par le délai de
// l'interface (30 s), pendant que le moteur, lui, continuait — et un second
// clic empilait un deuxième bench derrière le premier. Désormais :
//
// POST /api/bench lance (202 + id), 409 si le moteur est occupé
// GET /api/bench/status phase en cours, puis résultat ou erreur
// POST /api/bench/cancel annule (chaque requête au moteur porte le contexte)
// GET /api/bench/last dernier résultat enregistré
//
// Pendant toute la mesure, le bench tient le verrou de génération de la
// conversation : un message, une tâche planifiée ou une compaction reçoivent
// un refus clair au lieu d'attendre des minutes dans la file du moteur. Les
// clients /v1 reçoivent un 503 avec Retry-After (llm_oai.go). Restent hors du
// verrou les appels annexes déclenchés par la fin d'un tour (titre, mémoire) :
// le verrou les précède de toute façon.
var errBenchBusy = errors.New("un benchmark occupe le moteur — attends sa fin ou annule-le")
// benchLease prend le verrou de génération pour un benchmark. cancel est
// branché sur le bouton stop du chat, comme pour une tâche planifiée. La
// fonction rendue le libère tant que benching dit que le verrou est encore au
// bench : un Reset l'a peut-être déjà rendu (benching remis à faux), et le tour
// qui a pu démarrer depuis ne doit pas être déclaré libre. L'epoch ne suffit
// pas : changer de discussion le bumpe SANS arrêter le bench (loadFrom).
func (c *Conversation) benchLease(cancel context.CancelFunc) (func(), error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.Generating {
return nil, ErrBusy
}
c.Generating, c.benching, c.cancel = true, true, cancel
var once sync.Once
return func() {
once.Do(func() {
c.mu.Lock()
if c.benching {
c.Generating, c.benching, c.cancel = false, false, nil
}
c.mu.Unlock()
})
}, nil
}
// benchJob : le benchmark en cours ou le dernier terminé (un seul à la fois).
var benchJob struct {
mu sync.Mutex
seq int
running bool
id string
mode string
phase string
step int
steps int
started time.Time
finished time.Time
cancel context.CancelFunc
result *benchResult
err string
canceled bool
}
// benchRunning : un benchmark de ce processus est-il en cours ?
func benchRunning() bool {
benchJob.mu.Lock()
defer benchJob.mu.Unlock()
return benchJob.running
}
// benchRunner est runBench, remplaçable dans les tests.
var benchRunner = runBench
// benchStart lance un benchmark en arrière-plan. code : le statut HTTP à
// renvoyer en cas de refus.
func benchStart(opts benchOpts) (id string, code int, err error) {
// Hors verrou : healthCheck peut attendre le moteur quelques secondes, le
// suivi de progression ne doit pas rester bloqué derrière.
if externalActive() {
return "", http.StatusBadRequest, errors.New("benchmark indisponible : le preset actif est une API externe")
}
if !healthCheck() {
return "", http.StatusServiceUnavailable, errModelLoading
}
benchJob.mu.Lock()
defer benchJob.mu.Unlock()
if benchJob.running {
return "", http.StatusConflict, errors.New("un benchmark tourne déjà")
}
ctx, cancel := context.WithCancel(context.Background())
release, err := conv.benchLease(cancel)
if err != nil {
cancel()
return "", http.StatusConflict, conv.busyReason()
}
benchJob.seq++
id = fmt.Sprintf("b%d-%d", time.Now().Unix(), benchJob.seq)
benchJob.running, benchJob.id, benchJob.mode = true, id, opts.Mode
benchJob.phase, benchJob.step, benchJob.steps = "démarrage", 0, 0
benchJob.started, benchJob.finished = time.Now(), time.Time{}
benchJob.cancel, benchJob.result, benchJob.err, benchJob.canceled = cancel, nil, "", false
go func() {
defer cancel()
res, err := benchRunner(ctx, opts, func(phase string, step, steps int) {
benchJob.mu.Lock()
if benchJob.id == id {
benchJob.phase, benchJob.step, benchJob.steps = phase, step, steps
}
benchJob.mu.Unlock()
})
canceled := ctx.Err() != nil
// Le verrou du chat est rendu AVANT d'annoncer la fin : l'interface qui
// lit « terminé » et envoie aussitôt un message ne doit pas se voir
// refuser « benchmark en cours ».
release()
benchJob.mu.Lock()
defer benchJob.mu.Unlock()
if benchJob.id != id {
return
}
benchJob.running, benchJob.finished, benchJob.cancel = false, time.Now(), nil
benchJob.result = res
if err != nil {
benchJob.canceled = canceled
benchJob.err = err.Error()
if canceled {
benchJob.err = "benchmark annulé"
}
}
}()
return id, http.StatusAccepted, nil
}
// benchCancel annule le benchmark en cours. false : aucun ne tournait.
func benchCancel() bool {
benchJob.mu.Lock()
defer benchJob.mu.Unlock()
if !benchJob.running || benchJob.cancel == nil {
return false
}
benchJob.cancel()
return true
}
// benchStatus : l'état à renvoyer à l'interface.
func benchStatus() map[string]any {
benchJob.mu.Lock()
defer benchJob.mu.Unlock()
st := map[string]any{"ok": true, "running": benchJob.running, "id": benchJob.id, "mode": benchJob.mode}
if benchJob.id == "" {
return st
}
st["phase"], st["step"], st["steps"] = benchJob.phase, benchJob.step, benchJob.steps
end := benchJob.finished
if benchJob.running {
end = time.Now()
}
st["elapsed_sec"] = end.Sub(benchJob.started).Seconds()
if !benchJob.running {
if benchJob.result != nil {
st["result"] = benchJob.result
st["saved"] = benchSavable(benchJob.result)
}
if benchJob.err != "" {
st["error"] = benchJob.err
st["canceled"] = benchJob.canceled
}
}
return st
}
// handleBench lance un benchmark : POST seulement — il occupe le moteur des
// minutes durant, une balise <img> sur une page tierce ne doit pas y suffire.
// Corps : {"mode":"quick"|"full", "prompt":N, "n":N}, tous facultatifs.
func handleBench(w http.ResponseWriter, r *http.Request) {
if !postOnly(w, r) {
return
}
var body struct {
Mode string `json:"mode"`
Prompt int `json:"prompt"`
N int `json:"n"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
opts := benchOpts{Mode: benchModeQuick, Prompt: 2000, Predict: 300}
if strings.EqualFold(strings.TrimSpace(body.Mode), benchModeFull) {
opts.Mode = benchModeFull
}
if body.Prompt > 0 {
opts.Prompt = min(body.Prompt, 32768)
}
if body.N > 0 {
opts.Predict = min(body.N, 4096)
}
id, code, err := benchStart(opts)
if err != nil {
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, code, map[string]any{"ok": true, "id": id})
}
// handleBenchStatus : progression du benchmark en cours, ou issue du dernier.
func handleBenchStatus(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, benchStatus())
}
// handleBenchCancel annule le benchmark en cours.
func handleBenchCancel(w http.ResponseWriter, r *http.Request) {
if !postOnly(w, r) {
return
}
sendJSON(w, 200, map[string]any{"ok": benchCancel()})
}
// handleBenchLast returns the most recent persisted benchmark, or {ok:false}
// when none has been run yet.
func handleBenchLast(w http.ResponseWriter, r *http.Request) {
sb := loadLastBench()
if sb == nil {
sendJSON(w, 200, map[string]any{"ok": false})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "result": sb.Result, "model": sb.Model, "at": sb.At})
}
+3 -3
View File
@@ -5,13 +5,13 @@ import "testing"
// Un bench n'est affiché que pour le modèle sur lequel il a été mesuré.
func TestBenchMatchesPreset(t *testing.T) {
sb := savedBench{Model: "Qwen3-27B-Q4_K_M.gguf"}
if !benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Qwen3-27B-Q4_K_M.gguf"}) {
if !benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Qwen3-27B-Q4_K_M.gguf"}, nil) {
t.Fatal("même modèle : le bench doit s'afficher")
}
if benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Autre.gguf"}) {
if benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Autre.gguf"}, nil) {
t.Fatal("autre modèle : le bench ne doit pas s'afficher")
}
if benchMatchesPreset(sb, map[string]string{}) {
if benchMatchesPreset(sb, map[string]string{}, nil) {
t.Fatal("preset sans modèle : pas de bench")
}
}
+639
View File
@@ -0,0 +1,639 @@
package loki
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"unicode/utf8"
)
// fakeBenchEngine simule llama-server pour le protocole du bench : un compte
// de jetons à 4 octets par jeton, un cache qui reprend le prompt précédent
// quand cache_prompt est vrai, et le journal des requêtes de chat reçues.
type fakeBenchEngine struct {
mu sync.Mutex
nCtx int
busy bool
failAt int // n° (1-based) de la requête de chat qui répond 500 ; 0 = aucune
noTiming bool // réponses sans timings
rejectEffort string // niveau de reasoning_effort que le « gabarit » refuse (500)
slowHot time.Duration // délai des tours qui reprennent le cache
prev int
calls []map[string]any
}
func (f *fakeBenchEngine) handler(t *testing.T) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/health":
w.Write([]byte(`{"status":"ok"}`))
case "/slots":
fmt.Fprintf(w, `[{"id":0,"is_processing":%v}]`, f.busy)
case "/props":
fmt.Fprintf(w, `{"build_info":"b9999-test","total_slots":1,"default_generation_settings":{"n_ctx":%d}}`, f.nCtx)
case "/tokenize":
var b struct {
Content string `json:"content"`
}
json.NewDecoder(r.Body).Decode(&b)
toks := make([]int, len(b.Content)/4)
json.NewEncoder(w).Encode(map[string]any{"tokens": toks})
case "/v1/chat/completions":
var p map[string]any
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
t.Errorf("corps illisible : %v", err)
}
f.mu.Lock()
f.calls = append(f.calls, p)
n := len(f.calls)
f.mu.Unlock()
if f.failAt == n {
http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError)
return
}
if e, _ := p["reasoning_effort"].(string); f.rejectEffort != "" && e == f.rejectEffort {
// Le vrai message cite la trace jinja d'abord : les niveaux acceptés
// arrivent bien après les 300 premiers caractères.
http.Error(w, `{"error":{"code":500,"message":"`+strings.Repeat("jinja trace ", 40)+
`Unexpected reasoning effort `+e+`. Supported types are xhigh (default), medium, and low."}}`, http.StatusInternalServerError)
return
}
cache, _ := p["cache_prompt"].(bool)
if d := f.slowHot; cache && d > 0 {
select {
case <-time.After(d):
case <-r.Context().Done():
return
}
}
total := 0
for _, m := range p["messages"].([]any) {
c, _ := m.(map[string]any)["content"].(string)
total += len(c)/4 + 4
}
cached := 0
f.mu.Lock()
if cache {
cached = min(f.prev, total)
}
f.prev = total
f.mu.Unlock()
gen := int(p["max_tokens"].(float64))
resp := map[string]any{
"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "réponse"}}},
"usage": map[string]any{"prompt_tokens": total, "completion_tokens": gen},
}
if !f.noTiming {
resp["timings"] = map[string]any{
"prompt_n": total - cached, "prompt_ms": float64(total-cached) / 2, "prompt_per_second": 2000.0,
"predicted_n": gen, "predicted_ms": float64(gen) * 20, "predicted_per_second": 50.0,
"cache_n": cached, "draft_n": 10, "draft_n_accepted": 7,
}
}
json.NewEncoder(w).Encode(resp)
default:
http.NotFound(w, r)
}
})
}
func (f *fakeBenchEngine) start(t *testing.T) (benchEngine, *httptest.Server) {
srv := httptest.NewServer(f.handler(t))
t.Cleanup(srv.Close)
return benchEngine{base: srv.URL, client: srv.Client()}, srv
}
// testBenchCorpora : un code de 200 Ko sans aucune ligne répétée.
func testBenchCorpora() benchCorpora {
var b strings.Builder
for i := 0; b.Len() < 200<<10; i++ {
fmt.Fprintf(&b, "const ligne%06d = « contenu distinct %d » ;\n", i, i*7)
}
return benchCorpora{prose: benchCorpus, code: b.String()}
}
func testBenchSetup() benchSetup {
return benchSetupFrom(map[string]string{"TOP_K": "40", "MIN_P": "0.05", "REASONING": "off"}, nil)
}
// Le protocole complet, requête par requête : préchauffage, ligne courte et
// prefill à froid sans cache, puis trois tours qui prolongent la MÊME
// discussion avec le cache — et l'échantillonnage du preset partout.
func TestBenchProtocoleComplet(t *testing.T) {
f := &fakeBenchEngine{nCtx: 16384}
eng, _ := f.start(t)
var phases []string
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(),
func(phase string, step, steps int) {
phases = append(phases, fmt.Sprintf("%d/%d %s", step, steps, phase))
})
if err != nil {
t.Fatal(err)
}
if len(f.calls) != 6 {
t.Fatalf("%d requêtes de chat, attendu 6 (préchauffage, ligne courte, froid, 3 tours)", len(f.calls))
}
wantCache := []bool{false, false, false, true, true, true}
wantMax := []float64{benchWarmGen, 300, benchGenTokens, benchGenTokens, benchGenTokens, benchGenTokens}
wantMsgs := []int{1, 1, 1, 3, 5, 7}
for i, p := range f.calls {
if p["cache_prompt"] != wantCache[i] {
t.Errorf("requête %d : cache_prompt=%v, attendu %v", i+1, p["cache_prompt"], wantCache[i])
}
if p["max_tokens"] != wantMax[i] {
t.Errorf("requête %d : max_tokens=%v, attendu %v", i+1, p["max_tokens"], wantMax[i])
}
msgs := p["messages"].([]any)
if len(msgs) != wantMsgs[i] {
t.Errorf("requête %d : %d messages, attendu %d", i+1, len(msgs), wantMsgs[i])
}
for j, m := range msgs {
want := "user"
if j%2 == 1 {
want = "assistant"
}
if role := m.(map[string]any)["role"]; role != want {
t.Errorf("requête %d, message %d : rôle %v, attendu %s", i+1, j, role, want)
}
}
if p["top_k"] != float64(40) || p["min_p"] != 0.05 || p["seed"] != float64(benchSeed) {
t.Errorf("requête %d : échantillonnage du preset ou seed absents : %v", i+1, p)
}
if _, ok := p["ignore_eos"]; ok {
t.Errorf("requête %d : ignore_eos envoyé — génération hors distribution", i+1)
}
if kw, _ := p["chat_template_kwargs"].(map[string]any); kw["enable_thinking"] != false {
t.Errorf("requête %d : REASONING=off non transmis au gabarit : %v", i+1, p["chat_template_kwargs"])
}
}
// Chaque tour apporte du code JAMAIS vu : aucun morceau ne se répète.
seen := map[string]bool{}
for _, m := range f.calls[5]["messages"].([]any) {
c := m.(map[string]any)["content"].(string)
if c != "réponse" && seen[c] {
t.Error("un morceau de corpus est renvoyé deux fois")
}
seen[c] = true
}
if len(phases) != 6 || !strings.HasPrefix(phases[5], "6/6 ") {
t.Errorf("progression inattendue : %v", phases)
}
d := res.Depth
if d == nil || d.Skipped != "" || d.Partial != "" || d.Cold == nil || len(d.Turns) != benchTurns {
t.Fatalf("phase en profondeur incomplète : %+v", d)
}
if want, _ := benchDepthFor(16384, false, 2*512+benchWarmGen); d.Target != want || d.Cold.New < want*9/10 {
t.Errorf("profondeur : visée %d (attendu %d), froid %d jetons", d.Target, want, d.Cold.New)
}
if d.Reuse < 0.99 || d.ReuseNote != "" {
t.Errorf("reprise du cache : %v %q, attendu complète", d.Reuse, d.ReuseNote)
}
if d.DecodePerSec != 50 || d.CachedPerSec != 2000 {
t.Errorf("agrégats : decode %v, prefill %v", d.DecodePerSec, d.CachedPerSec)
}
if d.Draft == nil || d.Draft.N != 30 || d.Draft.Accepted != 21 || res.Draft == nil || res.Draft.N != 10 {
t.Errorf("acceptation du brouillon : profondeur %+v, ligne courte %+v", d.Draft, res.Draft)
}
if res.Engine != "b9999-test" || res.Protocol != benchProtocol || res.Mode != benchModeFull || !benchSavable(res) {
t.Errorf("métadonnées : %+v", res)
}
}
// Le mode rapide s'arrête à la ligne courte.
func TestBenchModeRapide(t *testing.T) {
f := &fakeBenchEngine{nCtx: 16384}
eng, _ := f.start(t)
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeQuick}, testBenchSetup(), testBenchCorpora(), nil)
if err != nil {
t.Fatal(err)
}
if len(f.calls) != 2 || res.Depth != nil || res.PredictedPerSec != 50 {
t.Fatalf("%d requêtes, profondeur %+v, decode %v", len(f.calls), res.Depth, res.PredictedPerSec)
}
}
// Une réponse non-200 ou sans timings n'est jamais une mesure : erreur, donc
// rien d'enregistré (runBench n'enregistre qu'un résultat sans erreur).
func TestBenchEchecsNonEnregistres(t *testing.T) {
for _, c := range []struct {
name string
f *fakeBenchEngine
want string
}{
{"500 sur la ligne courte", &fakeBenchEngine{nCtx: 16384, failAt: 2}, "ligne courte"},
{"500 sur un tour", &fakeBenchEngine{nCtx: 16384, failAt: 5}, "tour 2"},
{"sans timings", &fakeBenchEngine{nCtx: 16384, noTiming: true}, errBenchNoTimings.Error()},
{"moteur occupé", &fakeBenchEngine{nCtx: 16384, busy: true}, "déjà une requête"},
} {
t.Run(c.name, func(t *testing.T) {
eng, _ := c.f.start(t)
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(), nil)
if err == nil || res != nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("res=%v err=%v, attendu une erreur contenant %q", res, err, c.want)
}
if c.f.busy && len(c.f.calls) != 0 {
t.Error("moteur occupé : aucune requête ne devait partir")
}
})
}
}
// Contexte trop petit : la profondeur est sautée, la ligne courte reste.
func TestBenchProfondeurSautee(t *testing.T) {
f := &fakeBenchEngine{nCtx: 8192}
eng, _ := f.start(t)
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(), nil)
if err != nil {
t.Fatal(err)
}
if res.Depth == nil || res.Depth.Skipped == "" || len(f.calls) != 2 || !benchSavable(res) {
t.Fatalf("profondeur %+v, %d requêtes", res.Depth, len(f.calls))
}
}
// Un tour qui dépasse son budget rend un résultat PARTIEL, montré mais jamais
// enregistré.
func TestBenchBudgetPartiel(t *testing.T) {
old := benchReqTimeout
benchReqTimeout = 200 * time.Millisecond
t.Cleanup(func() { benchReqTimeout = old })
f := &fakeBenchEngine{nCtx: 16384, slowHot: 2 * time.Second}
eng, _ := f.start(t)
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(), nil)
if err != nil {
t.Fatal(err)
}
if res.Depth == nil || !strings.Contains(res.Depth.Partial, "tour 1") || benchSavable(res) {
t.Fatalf("attendu un résultat partiel non enregistrable : %+v", res.Depth)
}
}
// Annulé : erreur, rien d'enregistré.
func TestBenchAnnule(t *testing.T) {
f := &fakeBenchEngine{nCtx: 16384, slowHot: 5 * time.Second}
eng, _ := f.start(t)
ctx, cancel := context.WithCancel(context.Background())
go func() { time.Sleep(300 * time.Millisecond); cancel() }()
res, err := benchRun(ctx, eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(), nil)
if err == nil || res != nil {
t.Fatalf("annulation : res=%v err=%v", res, err)
}
}
func TestBenchDepthFor(t *testing.T) {
warm := 2*512 + benchWarmGen
for _, c := range []struct {
ctx int
cpu bool
want int
skipped bool
comments string
}{
{131072, false, benchDepthMax, false, "grand contexte : plafond"},
{131072, true, benchDepthCPU, false, "poids sur CPU : plafond réduit"},
{65536, false, 32768, false, "moitié du contexte"},
{16384, false, 16384 - benchTurns*(benchAppendTokens+benchGenTokens) - benchGenTokens - warm - benchMargin, false, "les tours doivent tenir"},
{8192, false, 0, true, "trop petit"},
{0, false, 0, true, "inconnu"},
} {
got, why := benchDepthFor(c.ctx, c.cpu, warm)
if got != c.want || (why != "") != c.skipped {
t.Errorf("%s : benchDepthFor(%d) = %d %q", c.comments, c.ctx, got, why)
}
if got > 0 && got+benchTurns*(benchAppendTokens+benchGenTokens)+benchGenTokens > c.ctx {
t.Errorf("%s : %d + tours déborde %d", c.comments, got, c.ctx)
}
}
}
func TestBenchKVLabel(t *testing.T) {
for _, c := range []struct {
k, v, label string
quant bool
}{
{"", "", "f16", false},
{"f16", "", "f16", false},
{"bf16", "bf16", "bf16", false},
{"q8_0", "q8_0", "q8_0", true},
{"q8_0", "", "q8_0/f16", true},
{"", "q4_0", "f16/q4_0", true},
} {
label, quant := benchKVLabel(c.k, c.v)
if label != c.label || quant != c.quant {
t.Errorf("benchKVLabel(%q,%q) = %q,%v ; attendu %q,%v", c.k, c.v, label, quant, c.label, c.quant)
}
}
// Le -ctk d'EXTRA_ARGS l'emporte, comme au lancement.
s := benchSetupFrom(map[string]string{"KV_TYPE": "f16", "EXTRA_ARGS": "-ctk q8_0 -ctv q8_0 --n-cpu-moe 20"}, nil)
if s.kv != "q8_0" || !s.kvQuant || !s.cpuPlaced {
t.Errorf("réglages tirés du preset : %+v", s)
}
}
func TestBenchPayload(t *testing.T) {
for _, c := range []struct {
name string
cfg map[string]string
want map[string]any
none []string
}{
{"échantillonnage du preset", map[string]string{"TEMP": "0.6", "TOP_P": "0.95", "TOP_K": "20"},
map[string]any{"temperature": 0.6, "top_p": 0.95, "top_k": 20, "seed": benchSeed}, []string{"chat_template_kwargs", "reasoning_effort"}},
{"sans réglage", map[string]string{},
map[string]any{"temperature": 0.7, "seed": benchSeed}, []string{"top_k", "chat_template_kwargs"}},
{"effort explicite", map[string]string{"REASONING_EFFORT": "high"},
map[string]any{"reasoning_effort": "high"}, nil},
} {
t.Run(c.name, func(t *testing.T) {
testHome(t) // effortResolve lit le modèle de la configuration
p := benchSetupFrom(c.cfg, nil).payload([]Message{{Role: "user", Content: "x"}}, 10, true)
for k, v := range c.want {
if fmt.Sprint(p[k]) != fmt.Sprint(v) {
t.Errorf("%s = %v, attendu %v", k, p[k], v)
}
}
for _, k := range append(c.none, "ignore_eos") {
if _, ok := p[k]; ok {
t.Errorf("%s ne devait pas être envoyé : %v", k, p[k])
}
}
if p["cache_prompt"] != true || p["stream"] != false {
t.Errorf("cache_prompt/stream : %v", p)
}
})
}
}
func TestBenchSlice(t *testing.T) {
text := strings.Repeat("ééé ligne\n", 100)
off := 0
for off < len(text) {
chunk, next := benchSlice(text, off, 37)
if chunk == "" || !utf8.ValidString(chunk) {
t.Fatalf("morceau invalide à %d : %q", off, chunk)
}
if next < len(text) && !strings.HasSuffix(chunk, "\n") {
t.Errorf("morceau coupé hors fin de ligne : %q", chunk)
}
off = next
}
if c, _ := benchSlice(text, len(text), 10); c != "" {
t.Error("corpus épuisé : morceau vide attendu")
}
if got := benchLineStart("ab\ncd", 1); got != 3 {
t.Errorf("benchLineStart = %d", got)
}
}
func TestBenchReuse(t *testing.T) {
full := []benchTurn{{Cached: 1000, Expected: 1004}, {Cached: 3000, Expected: 3000}}
if r, note := benchReuse(full, false); r < 0.99 || note != "" {
t.Errorf("reprise complète : %v %q", r, note)
}
part := []benchTurn{{Cached: 512, Expected: 3000}}
if _, note := benchReuse(part, true); !strings.Contains(note, "hybride") {
t.Errorf("hybride : %q", note)
}
if _, note := benchReuse(part, false); !strings.Contains(note, "gabarit") {
t.Errorf("non hybride : %q", note)
}
if r, note := benchReuse([]benchTurn{{Cached: -1, Expected: 3000}}, false); r != -1 || note == "" {
t.Errorf("inconnu : %v %q", r, note)
}
}
func TestBenchThrashHint(t *testing.T) {
if h := benchThrashHint(100<<20, 256, 0); h != "" {
t.Errorf("100 Mo / 256 jetons : pas d'indication attendue, %q", h)
}
if h := benchThrashHint(3<<30, 768, 60<<30); !strings.Contains(h, "thrash") {
t.Errorf("1 Go / 256 jetons : indication attendue, %q", h)
}
}
// Une mesure enregistrée ne s'affiche que pour la configuration et les cartes
// sur lesquelles elle a été prise ; une mesure d'avant l'empreinte retombe sur
// le nom du modèle.
func TestBenchEmpreinte(t *testing.T) {
cfg := map[string]string{"MODEL": "/models/Q.gguf", "CTX": "32768", "CUDA_VISIBLE_DEVICES": "0,1"}
res := &benchResult{PromptPerSecond: 1, Depth: &benchDepth{Hint: "possible thrash"}}
sb := newSavedBench(res, cfg)
if sb.Fingerprint == "" || sb.Result.Depth.Hint != "" || res.Depth.Hint == "" {
t.Fatalf("enregistrement : %+v (l'indication ne s'enregistre pas, le résultat montré la garde)", sb)
}
preset := map[string]string{"MODEL": "/models/Q.gguf", "CTX": "32768"}
if !benchMatchesPreset(sb, preset, cfg) {
t.Error("même configuration, mêmes cartes : la pastille doit s'afficher")
}
if benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Q.gguf", "CTX": "65536"}, cfg) {
t.Error("contexte différent : pastille masquée attendue")
}
if benchMatchesPreset(sb, preset, map[string]string{"CUDA_VISIBLE_DEVICES": "1,0"}) {
t.Error("cartes dans un autre ordre : pastille masquée attendue")
}
own := map[string]string{"MODEL": "/models/Q.gguf", "CTX": "32768", "CUDA_VISIBLE_DEVICES": "0,1"}
if !benchMatchesPreset(sb, own, map[string]string{"CUDA_VISIBLE_DEVICES": "0"}) {
t.Error("preset qui impose ses cartes : ce sont elles qui comptent")
}
legacy := savedBench{Model: "Q.gguf"}
if !benchMatchesPreset(legacy, preset, cfg) {
t.Error("mesure d'avant l'empreinte : repli sur le nom du modèle")
}
}
// Le lancement par l'interface : POST seulement, 409 pendant une génération,
// puis un seul bench à la fois, qui tient le verrou du chat et s'annule.
func TestBenchHandlers(t *testing.T) {
testHome(t)
f := &fakeBenchEngine{nCtx: 16384}
_, srv := f.start(t)
u, _ := url.Parse(srv.URL)
if err := WriteConfig(map[string]string{"PORT": u.Port()}); err != nil {
t.Fatal(err)
}
started := make(chan struct{})
old := benchRunner
benchRunner = func(ctx context.Context, o benchOpts, p benchProgress) (*benchResult, error) {
p("mesure", 1, 2)
close(started)
<-ctx.Done()
return nil, ctx.Err()
}
t.Cleanup(func() { benchRunner = old })
do := func(method, path, body string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
req := httptest.NewRequest(method, path, strings.NewReader(body))
switch path {
case "/api/bench":
handleBench(rec, req)
case "/api/bench/cancel":
handleBenchCancel(rec, req)
default:
handleBenchStatus(rec, req)
}
return rec
}
if rec := do(http.MethodGet, "/api/bench", ""); rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != http.MethodPost {
t.Fatalf("GET : %d, Allow=%q", rec.Code, rec.Header().Get("Allow"))
}
conv.mu.Lock()
conv.Generating = true
conv.mu.Unlock()
rec := do(http.MethodPost, "/api/bench", `{"mode":"full"}`)
conv.mu.Lock()
conv.Generating = false
conv.mu.Unlock()
if rec.Code != http.StatusConflict {
t.Fatalf("pendant une génération : %d, attendu 409", rec.Code)
}
if rec := do(http.MethodPost, "/api/bench", `{"mode":"full"}`); rec.Code != http.StatusAccepted {
t.Fatalf("lancement : %d %s", rec.Code, rec.Body)
}
<-started
if rec := do(http.MethodPost, "/api/bench", `{}`); rec.Code != http.StatusConflict {
t.Errorf("second bench : %d, attendu 409", rec.Code)
}
if err := conv.StartTurn("bonjour", nil, Caps{}, 0); err != ErrBusy {
t.Errorf("tour de chat pendant le bench : %v, attendu ErrBusy", err)
}
if err := conv.busyReason(); err != errBenchBusy {
t.Errorf("motif du refus : %v", err)
}
if queued, err := conv.EnqueueOrStart("cid-bench", "x", nil, Caps{}, 0); queued || err == nil {
t.Errorf("message pendant le bench : mis en file=%v err=%v, attendu un refus", queued, err)
}
var st map[string]any
json.Unmarshal(do(http.MethodGet, "/api/bench/status", "").Body.Bytes(), &st)
if st["running"] != true || st["phase"] != "mesure" || st["mode"] != benchModeFull {
t.Errorf("statut en cours : %v", st)
}
if rec := do(http.MethodPost, "/api/bench/cancel", ""); rec.Code != 200 {
t.Fatalf("annulation : %d", rec.Code)
}
deadline := time.Now().Add(3 * time.Second)
for benchRunning() && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
json.Unmarshal(do(http.MethodGet, "/api/bench/status", "").Body.Bytes(), &st)
if st["running"] != false || st["canceled"] != true {
t.Errorf("statut après annulation : %v", st)
}
if conv.isGenerating() {
t.Error("verrou de génération non rendu après le bench")
}
}
// Les clients /v1 reçoivent un 503 à retenter pendant un benchmark.
func TestBenchRefuseV1(t *testing.T) {
benchJob.mu.Lock()
benchJob.running = true
benchJob.mu.Unlock()
t.Cleanup(func() {
benchJob.mu.Lock()
benchJob.running = false
benchJob.mu.Unlock()
})
rec := httptest.NewRecorder()
oaiHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader("{}")))
if rec.Code != http.StatusServiceUnavailable || rec.Header().Get("Retry-After") == "" {
t.Fatalf("pendant le bench : %d, Retry-After=%q", rec.Code, rec.Header().Get("Retry-After"))
}
}
// Un gabarit qui refuse le niveau de raisonnement du preset : le bench rejoue
// une fois avec le niveau accepté, comme le chat, et le retient — au lieu
// d'échouer sur le 500.
func TestBenchNiveauRaisonnementRefuse(t *testing.T) {
f := &fakeBenchEngine{nCtx: 16384, rejectEffort: "high"}
eng, _ := f.start(t)
s := benchSetupFrom(map[string]string{"REASONING_EFFORT": "high"}, nil)
var learned []string
s.learn = func(want, got string) { learned = append(learned, want+">"+got) }
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeQuick}, s, testBenchCorpora(), nil)
if err != nil {
t.Fatalf("refus du niveau non rattrapé : %v", err)
}
if len(f.calls) != 3 || res.PredictedPerSec != 50 {
t.Fatalf("%d requêtes (attendu 3 : refus, préchauffage rejoué, ligne courte)", len(f.calls))
}
for i, p := range f.calls[1:] {
kw, _ := p["chat_template_kwargs"].(map[string]any)
if p["reasoning_effort"] != "xhigh" || kw["reasoning_effort"] != "xhigh" {
t.Errorf("requête %d : niveau %v / %v, attendu xhigh", i+2, p["reasoning_effort"], kw)
}
}
if len(learned) != 1 || learned[0] != "high>xhigh" {
t.Errorf("traduction retenue : %v", learned)
}
// Une autre erreur que ce refus n'est pas rejouée.
f2 := &fakeBenchEngine{nCtx: 16384, failAt: 1}
eng2, _ := f2.start(t)
if _, err := benchRun(context.Background(), eng2, benchOpts{}, benchSetupFrom(map[string]string{"REASONING_EFFORT": "high"}, nil), testBenchCorpora(), nil); err == nil || len(f2.calls) != 1 {
t.Errorf("500 ordinaire : err=%v, %d requêtes", err, len(f2.calls))
}
}
// Un gros ubatch sur un petit contexte : le préchauffage reste sous le quart
// de la fenêtre au lieu de la dépasser.
func TestBenchPrechauffageBorne(t *testing.T) {
f := &fakeBenchEngine{nCtx: 8192}
eng, _ := f.start(t)
s := benchSetupFrom(map[string]string{"UBATCH": "4096"}, nil)
if _, err := benchRun(context.Background(), eng, benchOpts{}, s, testBenchCorpora(), nil); err != nil {
t.Fatal(err)
}
c := f.calls[0]["messages"].([]any)[0].(map[string]any)["content"].(string)
if toks := len(c) / 4; toks > 8192/4+64 {
t.Errorf("préchauffage de %d jetons sur 8192 de contexte", toks)
}
}
// Changer de discussion n'arrête pas le bench et ne rend pas le verrou ; un
// Reset l'arrête et le rend, et la libération tardive du bench ne déclare pas
// libre le tour qui a démarré depuis.
func TestBenchVerrouEtDiscussions(t *testing.T) {
testHome(t)
c := newTestConv()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
release, err := c.benchLease(cancel)
if err != nil {
t.Fatal(err)
}
c.loadFrom("autre", nil)
if ctx.Err() != nil || !c.isGenerating() || c.busyReason() != errBenchBusy {
t.Fatal("changer de discussion a arrêté le bench ou rendu son verrou")
}
release()
if c.isGenerating() {
t.Fatal("verrou non rendu à la fin du bench, après un changement de discussion")
}
ctx2, cancel2 := context.WithCancel(context.Background())
defer cancel2()
release2, err := c.benchLease(cancel2)
if err != nil {
t.Fatal(err)
}
c.Reset()
if ctx2.Err() == nil || c.isGenerating() {
t.Fatal("Reset doit arrêter le bench et rendre le verrou")
}
c.mu.Lock()
c.Generating = true // un tour démarré depuis
c.mu.Unlock()
release2()
if !c.isGenerating() {
t.Fatal("la libération tardive du bench a déclaré libre le tour suivant")
}
}
+488 -43
View File
@@ -233,13 +233,13 @@ func globalCaps() Caps {
// InjectSkills prepends context system messages to msgs: the decisive-agent
// preamble + machine briefing (when tools are enabled) and the lightweight
// skills directory (when skills are enabled). Merges with an existing system
// message if present.
func InjectSkills(msgs []Message, caps Caps) []Message {
// message if present. tools = the tools sent on this same turn (see prepareTurn).
func InjectSkills(msgs []Message, caps Caps, tools []Tool) []Message {
var parts []string
// Decisive-agent preamble (anti-loop) — only when the model actually has
// tools, otherwise it nudges a plain chat model to "call tools" it doesn't
// have, which leaks malformed tool-call text into the answer.
if bp := baseSystemPrompt(caps); bp != "" {
if bp := baseSystemPrompt(caps, tools); bp != "" {
parts = append(parts, bp)
}
if mp := machineSystemPrompt(caps); mp != "" {
@@ -260,6 +260,17 @@ func InjectSkills(msgs []Message, caps Caps) []Message {
return append([]Message{{Role: "system", Content: prefix}}, msgs...)
}
// prepareTurn calcule UNE fois les outils du tour, puis le préambule à partir
// d'eux : la séquence et les outils renvoyés vont ensemble à runChatTools.
//
// Les calculer deux fois (préambule, puis runChat) payait deux fois les
// tentatives de connexion MCP d'un serveur en panne, et laissait le préambule
// décrire un autre état que les schémas envoyés à côté.
func prepareTurn(msgs []Message, caps Caps) ([]Message, []Tool) {
tools := EnabledTools(caps)
return InjectSkills(msgs, caps, tools), tools
}
// steerSystem ajoute une consigne système SANS jamais créer un second message
// système ailleurs qu'en tête : elle est fusionnée dans celui d'ouverture, ou
// posée en première position s'il n'y en a pas.
@@ -540,7 +551,11 @@ type ToolUsedEvent struct {
// mem_add/mem_edit), diffusé ligne à ligne pendant que le modèle le tape,
// pour que la bulle se remplisse en direct au lieu de rester figée puis de
// s'ouvrir d'un coup. Transitoire : seul Diff (état final) est rejoué.
Body string
// Ce n'est que la FIN du corps (bodyTail) : BodyTail dit que le début a été
// omis, BodyLines donne le nombre réel de lignes pour le « +N ».
Body string
BodyTail bool
BodyLines int
// Diff : lignes ajoutées/retirées quand l'outil a MODIFIÉ quelque chose
// (edit, mem_add, mem_edit). L'UI les affiche en vert (+) et rouge (-).
Diff []DiffLine
@@ -661,16 +676,11 @@ func writeBodyKey(tool string) string {
return ""
}
// previewArg pulls the (possibly incomplete) string value of key out of a
// streaming tool-call arguments JSON, so the UI can show the command being
// typed live. Best-effort: it tolerates a truncated tail and basic escapes.
func previewArg(args, key string) string {
v, _ := previewArgDone(args, key)
return v
}
// previewArgDone : comme previewArg, et dit si la valeur est COMPLÈTE (guillemet
// fermant reçu) — de quoi agir sur un argument avant la fin du flux.
// previewArgDone pulls the (possibly incomplete) string value of key out of a
// streaming tool-call arguments JSON (best-effort: it tolerates a truncated tail
// and basic escapes), et dit si la valeur est COMPLÈTE (guillemet fermant reçu)
// — de quoi agir sur un argument avant la fin du flux. Pour l'affichage en
// direct, argPreview fait la même lecture sans tout relire à chaque morceau.
func previewArgDone(args, key string) (string, bool) {
i := strings.Index(args, "\""+key+"\"")
if i < 0 {
@@ -717,6 +727,169 @@ func previewArgDone(args, key string) (string, bool) {
return b.String(), closed
}
// argPreview : previewArgDone au fil du flux. Pendant l'écriture d'un fichier,
// previewArg relisait et redécodait tout le JSON des arguments à CHAQUE morceau
// — quadratique, et ce CPU-là manque au décodage quand des experts tournent sur
// le processeur. Ici on retient où on en est et on ne décode que les octets
// neufs. Même règle que previewArgDone : première occurrence de "clé", puis
// premier ':', puis premier '"' ; \r ignoré, échappement inconnu (\uXXXX
// compris) recopié tel quel sans la barre. Seule différence, sur un préfixe :
// une barre oblique finale reste en attente de son second octet au lieu d'être
// écrite — elle n'est pas encore un caractère.
type argPreview struct {
key string
pat string // `"key"`
pos int // octets de args déjà consommés
phase int // 0 clé, 1 ':', 2 '"' ouvrant, 3 valeur, 4 valeur fermée
val strings.Builder
lines int // '\n' de la valeur décodée
}
func newArgPreview(key string) *argPreview {
return &argPreview{key: key, pat: `"` + key + `"`}
}
// update consomme la suite de args, qui doit prolonger celui des appels
// précédents (les arguments d'un appel ne font que grandir pendant le flux).
func (a *argPreview) update(args string) {
for a.pos < len(args) && a.phase < 4 {
switch a.phase {
case 0:
// La clé peut être coupée entre deux morceaux : on reprend un peu avant.
s := a.pos - (len(a.pat) - 1)
if s < 0 {
s = 0
}
i := strings.Index(args[s:], a.pat)
if i < 0 {
a.pos = len(args)
return
}
a.pos, a.phase = s+i+len(a.pat), 1
case 1, 2:
sep := byte(':')
if a.phase == 2 {
sep = '"'
}
j := strings.IndexByte(args[a.pos:], sep)
if j < 0 {
a.pos = len(args)
return
}
a.pos += j + 1
a.phase++
case 3:
seg := args[a.pos:]
k := strings.IndexAny(seg, "\\\"")
if k < 0 {
k = len(seg)
}
a.val.WriteString(seg[:k])
a.lines += strings.Count(seg[:k], "\n")
a.pos += k
if k == len(seg) {
return
}
if seg[k] == '"' {
a.pos++
a.phase = 4
return
}
if k+1 >= len(seg) {
return // échappement coupé : on attend son second octet
}
switch e := seg[k+1]; e {
case 'n':
a.val.WriteByte('\n')
a.lines++
case 't':
a.val.WriteByte('\t')
case 'r':
default: // '"', '\\', '/', et l'inconnu recopié tel quel
a.val.WriteByte(e)
if e == '\n' {
a.lines++
}
}
a.pos += 2
}
}
}
// value : la valeur décodée jusqu'ici (sans copie).
func (a *argPreview) value() string { return a.val.String() }
// done : guillemet fermant reçu.
func (a *argPreview) done() bool { return a.phase == 4 }
// lineCount : bodyLineCount(value()) sans relire la valeur. Recompter les '\n'
// de tout le corps à chaque ligne émise restait quadratique.
func (a *argPreview) lineCount() int {
n := a.val.Len()
switch {
case n == 0:
return 0
case a.val.String()[n-1] != '\n':
return a.lines + 1
case n == 1:
return 0 // "\n" seul : bodyLineCount le compte vide
}
return a.lines
}
// bodyTailLines / bodyTailBytes : ce qu'un événement de frappe montre du corps.
const (
bodyTailLines = 40
bodyTailBytes = 4096
)
// bodyTail : la fin du corps en cours d'écriture, ses 40 dernières lignes et
// 4 Kio au plus, coupée en début de ligne (en début de caractère pour une ligne
// seule plus longue). cut : le début a été omis. La bulle ne fait défiler que
// la fin de toute façon, et le diff final reste complet.
func bodyTail(s string) (tail string, cut bool) {
lo := len(s) - bodyTailBytes
if lo < 0 {
lo = 0
}
start, n, end := -1, 0, len(s)
for {
i := strings.LastIndexByte(s[:end], '\n')
if i < 0 {
if lo == 0 {
start = 0
}
break
}
if i+1 < lo {
break
}
n++
start = i + 1
if n == bodyTailLines {
break
}
end = i
}
if start < 0 {
start = lo
for start < len(s) && !utf8.RuneStart(s[start]) {
start++
}
}
return s[start:], start > 0
}
// bodyLineCount : lignes du corps comme les compte l'UI (bodyLineCount en JS) —
// un saut de ligne final n'ouvre pas de ligne de plus.
func bodyLineCount(s string) int {
s = strings.TrimSuffix(s, "\n")
if s == "" {
return 0
}
return strings.Count(s, "\n") + 1
}
// StatsEvent carries llama.cpp's per-completion timing (final chunk).
type StatsEvent struct {
PromptTokens int `json:"prompt_tokens,omitempty"`
@@ -728,6 +901,39 @@ type StatsEvent struct {
// Taille TOTALE du prompt traité ce tour (préfixe caché compris), issue de
// `usage.prompt_tokens`. 0 si le backend ne renvoie pas d'usage.
PromptTokensTotal int `json:"prompt_tokens_total,omitempty"`
// Morceaux de raisonnement reçus SÉPARÉS par le moteur (reasoning_content),
// llama-server local seulement. Loki ne renvoie jamais ce raisonnement au
// modèle (Message n'a pas de champ pour lui) : ces jetons font partie de
// GenTokens mais pas de la requête suivante. Un morceau vaut au plus un
// jeton, donc ce compte ne peut que sous-estimer — le sens sans danger : on
// compacte au pire comme avant, jamais trop tard. Voir ctxAfter.
ReasoningTokens int `json:"reasoning_tokens,omitempty"`
// Conseil ponctuel quand le cache de prompts n'a pas tenu après un travail
// annexe (voir noteEnginePrompt). Vide la plupart du temps.
CacheHint string `json:"cache_hint,omitempty"`
// Télémétrie d'affichage (perf_log.go), jamais relue pour décider quoi que ce
// soit. Pointeurs : nil = le moteur ne l'a pas dit, ce qui n'est pas 0 — un
// cache_n à 0 (tout recalculé) est précisément ce qu'il faut pouvoir montrer.
CacheTokens *int `json:"cache_tokens,omitempty"`
DraftN *int `json:"draft_n,omitempty"`
DraftAccepted *int `json:"draft_accepted,omitempty"`
TTFTms *int64 `json:"ttft_ms,omitempty"`
// Posés sur le dernier événement de la complétion seulement, une fois
// celle-ci rangée : jetons du tour précédent qu'il a fallu recalculer, et
// s'il faut le signaler (seuil relevé sur un modèle hybride).
Lost *int `json:"lost,omitempty"`
LostAfter string `json:"lost_after,omitempty"`
LostAlert bool `json:"lost_alert,omitempty"`
Kind string `json:"kind,omitempty"`
}
// ctxAfter : taille du contexte que la requête SUIVANTE renverra réellement au
// moteur — le prompt de cette complétion plus ce qu'elle a généré, MOINS son
// raisonnement, jamais renvoyé. Compter ce raisonnement gonflait le contexte de
// 1 à 8 k jetons par étape, et la compaction (avec perte) partait trop tôt.
// Borné par GenTokens : le compte de morceaux ne retire jamais plus que généré.
func (s StatsEvent) ctxAfter() int {
return s.PromptTokensTotal + s.GenTokens - min(s.GenTokens, s.ReasoningTokens)
}
// ChatCallback receives stream events. Return false to abort the stream.
@@ -755,6 +961,10 @@ type streamChunk struct {
PredictedPerSec float64 `json:"predicted_per_second"`
} `json:"timings"`
// Chunk final (include_usage) : taille totale du prompt, hors choices.
// ⚠️ Rien de plus ici : un champ typé de travers fait échouer le décodage
// du chunk ENTIER, qui est sauté — texte final, finish_reason et usage
// compris. Les compteurs de télémétrie (cache_n, draft_n, cached_tokens) se
// lisent à part, sans jamais échouer (perfWire, perf_log.go).
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
@@ -893,8 +1103,13 @@ func toolCallLabel(name string, args map[string]any) string {
// prenne en compte dans la SUITE de sa réponse (AJEAN 0.14.0) au lieu
// d'obliger à arrêter puis relancer. Ajoutés à `messages` ET à `extra`.
func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback, injectQueued ...func() []Message) ([]Message, error) {
return runChatTools(ctx, messages, EnabledTools(caps), temperature, caps, cb, injectQueued...)
}
// runChatTools est runChat avec les outils déjà calculés par prepareTurn : ceux
// que décrit le préambule, à l'octet près.
func runChatTools(ctx context.Context, messages []Message, tools []Tool, temperature float64, caps Caps, cb ChatCallback, injectQueued ...func() []Message) ([]Message, error) {
var extra []Message
tools := EnabledTools(caps)
// Some backends (vanilla llama.cpp builds) don't populate `reasoning_content`
// in streaming mode: the model's <think> block (opened by the chat template)
// arrives inline in `content`, terminated by a literal </think>. When
@@ -902,6 +1117,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// bubble works regardless of backend. The ik_llama.cpp fork already sends
// reasoning_content, in which case we leave content untouched.
chatCfg := ReadConfig()
// Nature et conversation de ces complétions, pour la télémétrie seulement
// (perf_log.go) : rien de tout ça ne part dans la requête.
ptag, perfIter := perfTagOf(ctx), 0
reasoningOn := reasoningActive(chatCfg["REASONING"])
// Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide
// = on n'envoie rien. Réglage par preset, donc de fait par modèle.
@@ -922,6 +1140,15 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// retry the same turn once with tools removed so the model answers in plain
// text from the tool results already gathered, instead of dying mid-chat.
disableTools := false
// Première relance après ce 500, sur le llama-server local : outils GARDÉS
// à l'identique dans la requête, mais tool_choice « none » et la consigne en
// fin de fil (withTrailingHint). Le prompt rendu ne change pas jusqu'au
// dernier message, donc le cache sert presque tout ; retirer les outils
// changeait le gabarit dès le système et recalculait toute la conversation.
// Les appels éventuels sont ignorés (callsOn) ; le moindre écart — nouveau
// refus, appel émis malgré tout, réponse vide — retombe UNE fois sur le
// chemin historique (outils retirés, consigne via steerSystem).
toolChoiceNone := false
// Filet réactif (façon Hermes) : si llama-server refuse le prompt (souvent un
// dépassement de la fenêtre de contexte après de gros résultats d'outils), on
// compacte l'historique en vol et on rejoue le tour — une seule fois.
@@ -948,6 +1175,18 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// par rendre la main plutôt que faire attendre.
const maxNudges = 2
nudgeCount := 0
// Génération la plus longue de ce tour (llama-server local), pour la garde
// de marge de compactNeeded : le raisonnement n'étant plus compté dans le
// contexte, il faut s'assurer AUTREMENT qu'une étape aussi longue tient
// encore dans la fenêtre.
peakGen := 0
// Complétion coupée par la fenêtre pleine (finish_reason « length ») :
// compactée puis rejouée une fois, au lieu du nudge « arrête de raisonner »
// qui raccourcirait la réflexion du modèle.
lengthReplays := 0
// Contexte estimé pour la requête suivante (en-tour), comparé au compte
// réel du moteur quand il arrive — journalisé s'il s'en écarte.
lastEst := 0
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : relances
// consécutives bornées, compteur remis à zéro après chaque outil exécuté.
patternRetries := 0
@@ -993,19 +1232,30 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
}
}
// Rappel injecté EN FIN d'historique : le préfixe déjà en cache côté
// llama-server reste valide, seul le nouveau message est à traiter.
// llama-server reste valide, seul le nouveau message est à traiter. Et
// persisté tel quel (appendNudge) : le tour suivant le renvoie à
// l'identique au lieu de diverger juste avant lui.
if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" {
budgetNudges++
logBudget(toolRuns, budget, budgetNudges)
messages = append(messages, Message{Role: "user", Content: msg})
messages, extra = appendNudge(messages, extra, msg)
}
// Appels d'outils exécutables pour cette complétion : outils annoncés,
// et ni coupés ni neutralisés par tool_choice « none ».
callsOn := len(tools) > 0 && !disableTools && !toolChoiceNone
// Normalisé juste avant l'envoi : un seul système, en tête. Les gabarits
// stricts (Qwen3.x) refusent un système ailleurs qu'en position 0.
// Gardé à part : la télémétrie compare ces messages-là d'une requête à
// l'autre (perfPrefix).
sent := normalizeSystemMessages(messages)
if toolChoiceNone {
sent = withTrailingHint(sent, toolsOffHint)
}
payload := map[string]any{
"model": ep.Model,
// Normalisé juste avant l'envoi : un seul système, en tête. Les gabarits
// stricts (Qwen3.x) refusent un système ailleurs qu'en position 0.
// Les images de l'historique y sont rangées par référence
// (chat_images.go) : on remet leurs octets juste avant l'envoi.
"messages": expandImageRefs(normalizeSystemMessages(messages)),
"messages": expandImageRefs(sent),
"stream": true,
"temperature": temperature,
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
@@ -1034,6 +1284,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// string ("{...}{...}") and then fails to parse (HTTP 500). Forcing a
// single tool call per turn avoids that.
payload["parallel_tool_calls"] = false
if toolChoiceNone {
payload["tool_choice"] = "none"
}
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
@@ -1046,8 +1299,16 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// temps qu'un 401 garanti. Chaque endpoint porte la sienne.
ep.auth(req.Header.Set)
tReq := time.Now() // secours des stats quand le serveur n'envoie pas de timings
// Requête en vol vers le moteur local, jusqu'à la lecture complète du corps :
// l'isolation des travaux annexes n'efface jamais le slot pendant ce temps
// (llm_slots.go). Rien à compter pour une API externe.
endReq := func() {}
if !ep.External {
endReq = engineRequestStart()
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
endReq()
// Rien n'est encore parti à l'écran : le tour est rejouable tel quel.
// C'est le cas du moteur qui redémarre (bascule de preset, rechargement
// de modèle) — quelques secondes de connexion refusée qui faisaient
@@ -1070,6 +1331,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2000))
resp.Body.Close()
endReq()
msg := strings.TrimSpace(string(b))
if msg == "" {
msg = resp.Status
@@ -1130,12 +1392,27 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// in plain text rather than leaving the chat dead. Seulement sur 500 :
// un 401, 429 ou 502 (API externe, passerelle) n'a rien à voir avec un
// appel mal formé, et couper les outils pour ça cassait le tour d'agent.
if resp.StatusCode == http.StatusInternalServerError && !disableTools && len(tools) > 0 {
//
// Sur le llama-server local, la première relance garde le prompt
// intact (voir toolChoiceNone). Une API externe garde le chemin
// historique : certaines passerelles OpenAI-compatibles traitent
// tool_choice à leur façon. Si la relance « none » est refusée à
// son tour (500, ou un 4xx d'un moteur qui ne connaîtrait pas le
// champ), on retombe sur le chemin historique au lieu de finir le
// tour.
if !disableTools && len(tools) > 0 && (resp.StatusCode == http.StatusInternalServerError ||
(toolChoiceNone && resp.StatusCode >= 400 && resp.StatusCode < 500)) {
if !ep.External && !toolChoiceNone {
toolChoiceNone = true
logCtx("relance sans outil après un 500 : tool_choice=none, prompt conservé")
continue
}
toolChoiceNone = false
disableTools = true
// Nudge the model to answer in plain text from what it already
// gathered, so it doesn't immediately re-emit a tool call that
// llama.cpp would again fail to parse.
messages = steerSystem(messages, "Do not call any more tools. Answer now, directly, in the user's language, using only the information already gathered.")
messages = steerSystem(messages, toolsOffHint)
continue
}
// Dernier recours, APRÈS les filets sémantiques ci-dessus : un statut
@@ -1162,8 +1439,19 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// Une réponse est arrivée : le budget de reprise réseau repart à neuf pour
// la suite de la boucle d'outils.
netRetries = 0
if !ep.External {
engineServed() // le slot porte désormais cette requête (llm_slots.go)
}
toolCalls := map[int]*ToolCall{}
// argBufs : arguments de chaque appel, accumulés sans recopie. `+=` sur la
// chaîne recopiait tout le JSON à chaque morceau — quadratique sur l'écriture
// d'un gros fichier. cur.Function.Arguments reste la valeur de référence :
// on la recale sur le tampon à chaque morceau (String() ne copie rien).
argBufs := map[int]*strings.Builder{}
assistantContent := strings.Builder{}
// retryScanned : longueur de assistantContent déjà balayée sans appel
// textuel (voir textualToolCallFrom).
retryScanned := 0
finishReason := ""
// Accumulateur de stats : timings (prefill/decode) puis usage (total prompt)
// arrivent sur des chunks séparés ; on émet une copie complète à chaque MAJ
@@ -1176,6 +1464,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
usageGen, genChunks := 0, 0
lastPreview := "" // last command preview emitted (to stream the typing)
lastBodyLines := -1 // lignes déjà diffusées du corps en cours d'écriture
// Lecture au fil de l'eau de l'argument affiché (commande, chemin…) et du
// corps d'une écriture : seuls les octets neufs sont décodés.
var labelDec, bodyDec *argPreview
// sentAnswer : du texte de réponse ou un outil est déjà parti vers l'UI pour
// cette complétion (une reprise le doublerait). sentReasoning : seul du
// raisonnement est parti, qu'on sait retirer (DropReasoning). shown : texte
@@ -1212,6 +1503,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
aborted := false
// patternHit : flux coupé sur un appel d'outil écrit en texte (voir plus bas).
patternHit := false
// noneLeak : sous tool_choice « none », le modèle a quand même tenté un
// appel (protocole ou texte) — voir toolChoiceNone.
noneLeak := false
// preflight : write/edit refusé dès son chemin (voir plus bas) ; non-nil
// mais vide = chemin déjà vérifié, rien à signaler.
var preflight *preflightRefusal
@@ -1232,6 +1526,13 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// build llama.cpp (MTP/spéculatif), a `choices:[]` — on les traite AVANT le
// garde de choices, sinon `gen_tokens`/`gen_per_second` (decode) sont jetés
// et l'UI retombe à « 0 tok/s » à la fin de la génération.
// Deuxième lecture, tolérante, pour la télémétrie : seulement sur les
// chunks qui portent timings ou usage, pas sur chaque jeton.
var pw perfWire
if chunk.Timings != nil || chunk.Usage != nil {
pw = decodePerfWire([]byte(data))
stats.applyPerf(pw)
}
if chunk.Timings != nil {
sawTimings = true
stats.PromptTokens = chunk.Timings.PromptN
@@ -1249,6 +1550,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
if chunk.Usage != nil && chunk.Usage.PromptTokens > 0 {
stats.PromptTokensTotal = chunk.Usage.PromptTokens
s := stats
if !ep.External {
cached := pw.cachedTokens()
s.CacheHint = noteEnginePrompt(chunk.Usage.PromptTokens, cached.int(), cached.ok)
}
scb(StreamEvent{Stats: &s})
}
if len(chunk.Choices) == 0 {
@@ -1271,7 +1576,13 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// relance sans outils après un 500 — le moteur peut quand même parser un
// appel que le modèle a émis de lui-même ; l'exécuter donnait « outil
// inconnu », réinjecté puis mis en boucle. On l'ignore (AJEAN 0.13.12).
if len(tools) > 0 && !disableTools && len(ch.Delta.ToolCalls) > 0 {
// Relance tool_choice « none » : un appel émis malgré tout n'est pas
// exécuté, on coupe et on retombe sur la relance sans outils.
if toolChoiceNone && len(ch.Delta.ToolCalls) > 0 {
noneLeak = true
break
}
if callsOn && len(ch.Delta.ToolCalls) > 0 {
// Un appel d'outil clôt le texte : on vide MAINTENANT le reliquat
// retenu par la garde « </think> » (voir plus bas). Sinon il n'était
// émis qu'en fin de flux, donc APRÈS l'événement d'outil, et l'UI
@@ -1307,7 +1618,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
if tc.Function.Name != "" {
cur.Function.Name = tc.Function.Name
}
cur.Function.Arguments += tc.Function.Arguments
ab := argBufs[idx]
if ab == nil {
ab = &strings.Builder{}
ab.WriteString(cur.Function.Arguments)
argBufs[idx] = ab
}
ab.WriteString(tc.Function.Arguments)
cur.Function.Arguments = ab.String()
}
// Stream the command being typed: extract the partial value and
// emit it whenever it grows, so the UI shows it appear live.
@@ -1333,25 +1651,40 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
case "git_clone":
key = "url"
}
p := previewArg(cur.Function.Arguments, key)
if labelDec == nil || labelDec.key != key {
labelDec = newArgPreview(key)
}
labelDec.update(cur.Function.Arguments)
p := labelDec.value()
// Corps en cours de frappe pour les outils d'écriture : on le diffuse
// à la LIGNE, pas au token. Un événement par token republierait tout le
// contenu à chaque fois (coût quadratique, et c'est ce flot qui saturait
// le rendu mobile) ; à la ligne, le nombre d'événements est celui du
// fichier et l'animation reste fluide.
body := ""
// fichier et l'animation reste fluide. Et chaque événement ne porte
// que la FIN du corps (bodyTail) : republier tout le fichier à chaque
// ligne restait quadratique — 250 Mo de flux pour 120 Ko écrits.
full := ""
if bk := writeBodyKey(cur.Function.Name); bk != "" {
body = previewArg(cur.Function.Arguments, bk)
if bodyDec == nil || bodyDec.key != bk {
bodyDec = newArgPreview(bk)
}
bodyDec.update(cur.Function.Arguments)
full = bodyDec.value()
}
grew := body != "" && strings.Count(body, "\n") > lastBodyLines
grew := full != "" && bodyDec.lines > lastBodyLines
if (p != "" && p != lastPreview) || grew {
if p != "" {
lastPreview = p
}
if grew {
lastBodyLines = strings.Count(body, "\n")
lastBodyLines = bodyDec.lines
}
if !scb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: cur.Function.Name, Label: lastPreview, Body: body, Typing: true}}) {
tu := &ToolUsedEvent{Name: cur.Function.Name, Label: lastPreview, Typing: true}
if full != "" {
tu.Body, tu.BodyTail = bodyTail(full)
tu.BodyLines = bodyDec.lineCount()
}
if !scb(StreamEvent{ToolUsed: tu}) {
aborted = true
break
}
@@ -1377,6 +1710,15 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// Backend already separates reasoning — trust it, disable our split.
sawReasoningField = true
thinkOpen = false
// Compté ICI seulement : le raisonnement découpé chez nous (<think>
// en ligne, plus bas) reste dans assistantContent et repart dans
// l'étape suivante — le retirer sous-estimerait le contexte. Pas
// pour une API externe : son découpage en morceaux ne dit rien du
// nombre de jetons, et son preset doit rester tel quel. stats est
// propre à cette tentative : une relance repart de zéro.
if !ep.External {
stats.ReasoningTokens++
}
if !scb(StreamEvent{Reasoning: ch.Delta.ReasoningContent}) {
aborted = true
break
@@ -1394,9 +1736,26 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// faux appel — parfois un fichier entier — qui ne sera jamais
// exécuté. La relance corrective part juste après (voir fin de
// boucle). Testé seulement quand le morceau peut ouvrir un motif.
if len(tools) > 0 && !disableTools && patternRetries < maxPatternRetries &&
strings.ContainsAny(ch.Delta.Content, "<`{[_.") && textualToolCall(assistantContent.String()) {
patternHit = true
// Balayage limité à ce qui suit le précédent (retryScanned, avancé
// seulement quand un balayage a VRAIMENT eu lieu : un morceau sans
// caractère déclencheur n'est pas relu, l'écart doit rester couvert).
// La passe complète de fin de tour, elle, reste entière.
hit := false
if (callsOn && patternRetries < maxPatternRetries || toolChoiceNone) &&
strings.ContainsAny(ch.Delta.Content, "<`{[_.") {
s := assistantContent.String()
hit = textualToolCallFrom(s, retryScanned)
retryScanned = len(s)
}
if hit {
// Sous tool_choice « none », le moteur ne parse plus les
// appels : le balisage arrive en texte. Ce n'est pas une
// réponse, on retombe sur la relance sans outils.
if toolChoiceNone {
noneLeak = true
} else {
patternHit = true
}
break
}
} else {
@@ -1464,6 +1823,13 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// arguments tronqués. On refuse donc le tour, en le disant.
scanErr := sc.Err()
resp.Body.Close()
endReq()
// Premier jeton, de quelque nature qu'il soit (raisonnement, texte ou
// appel d'outil). tReq est repris à chaque tentative.
if !tFirst.IsZero() {
ms := tFirst.Sub(tReq).Milliseconds()
stats.TTFTms = &ms
}
if !sawTimings && !tFirst.IsZero() {
// Décodage mesuré ici ; le 1er token tombe dans la lecture du prompt.
// Pas de débit de lecture : le serveur a pu réutiliser une partie du
@@ -1481,13 +1847,46 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
s := stats
cb(StreamEvent{Stats: &s})
}
// Télémétrie (perf_log.go). Une complétion coupée avant son chunk final
// (arrêt, appel écrit en texte, refus anticipé, flux cassé) n'a ni cache
// ni total fiables : rangée comme incomplète, sans calcul de perte.
complete := !aborted && (scanErr == nil || finishReason != "") && (sawTimings || stats.PromptTokensTotal > 0)
var prefix []uint64
if complete {
prefix = perfPrefix(sent)
}
rec := perfRecord(perfRecFromStats(ptag, perfIter, complete, stats), prefix)
perfIter++
if complete {
// Dernier événement de la complétion, copie COMPLÈTE des stats : rejoué
// depuis le journal, il ne fait que repeindre la même ligne.
s := stats
s.Kind, s.Lost, s.LostAfter = rec.Kind, rec.Lost, rec.LostAfter
// Pas d'alerte pour une API externe : son cache (s'il existe) suit ses
// propres règles, sans slot ni points de reprise à surveiller ici. Le
// relevé reste dans l'anneau, l'affichage ne change pas.
if !ep.External && ((rec.Lost != nil && *rec.Lost > 0) || (rec.Cached != nil && *rec.Cached == 0)) {
s.LostAlert = perfAlert(rec, perfLostAlertAt(chatCfg))
}
cb(StreamEvent{Stats: &s})
if !ep.External {
peakGen = max(peakGen, stats.GenTokens)
if lastEst > 0 && stats.PromptTokensTotal > 0 {
logCtxEstimate("en-tour", lastEst, stats.PromptTokensTotal)
}
}
lastEst = 0
}
if finishReason == "length" {
logCtx("finish=length prompt=%d généré=%d raisonnement=%d fenêtre=%d", stats.PromptTokensTotal, stats.GenTokens, stats.ReasoningTokens, ctxWindow())
}
if aborted {
return extra, nil
}
// Coupure APRÈS le dernier chunk (finish_reason reçu) : la réponse est
// complète, seule la fermeture a raté. On la garde telle quelle. Flux
// coupé par NOUS sur un appel écrit en texte : pas une panne non plus.
if scanErr != nil && (finishReason != "" || patternHit || (preflight != nil && preflight.msg != "")) {
if scanErr != nil && (finishReason != "" || patternHit || noneLeak || (preflight != nil && preflight.msg != "")) {
scanErr = nil
}
// Flux coupé vers une API DISTANTE (Wi-Fi, VPN, proxy qui décroche) : on
@@ -1532,6 +1931,23 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// rapprochée, pas pour tout un long tour d'agent.
streamRetries = 0
// Relance tool_choice « none » sans réponse exploitable (appel tenté
// malgré tout, balisage d'appel en texte, ou rien du tout) : repli UNE
// fois sur le chemin historique, outils retirés du gabarit. Le prompt est
// alors recalculé, mais le tour aboutit comme avant. Pas après un stop :
// la relance partirait sur un contexte annulé et afficherait une erreur.
if toolChoiceNone && ctx.Err() == nil && (noneLeak || textualToolCallSnippet(assistantContent.String()) != "" ||
strings.TrimSpace(assistantContent.String()) == "") {
logCtx("relance tool_choice=none sans réponse exploitable : repli sans outils")
toolChoiceNone = false
disableTools = true
if sentReasoning && !sentAnswer {
cb(StreamEvent{DropReasoning: true})
}
messages = steerSystem(messages, toolsOffHint)
continue
}
// Écriture refusée en plein flux (pré-vol) : l'appel, réduit à son chemin,
// entre dans l'historique avec le refus pour résultat — le modèle voit
// pourquoi, et la boucle repart pour qu'il lise le fichier d'abord.
@@ -1897,18 +2313,21 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// comptés. Sans eux, une étape qui lit plusieurs gros fichiers d'un coup
// passait de 60 % à plus de 130 % de la fenêtre sans jamais compacter.
// Serveur sans usage (base 0) : on laisse 0, compactWouldTrigger estime
// alors TOUT l'historique — étape comprise.
// alors TOUT l'historique — étape comprise. Le raisonnement de l'étape
// est retiré (ctxAfter) : il ne repart pas dans la requête suivante.
used := 0
if base := stats.PromptTokensTotal + stats.GenTokens; base > 0 {
if base := stats.ctxAfter(); base > 0 {
used = base + estimateTokens(messages[stepStart:])
lastEst = used
}
if compactWouldTrigger(messages, used) {
if compactNeeded(messages, used, peakGen) {
yes, no := true, false
cb(StreamEvent{Compacting: &yes})
c, changed := compactMessages(ctx, messages, caps)
cb(StreamEvent{Compacting: &no})
logCompact("en-tour", used, messages, c, changed)
if changed {
lastEst = 0 // estimation d'avant compaction : plus rien à comparer
messages = c
// La nouvelle base contient déjà tout ce tour : on la publie et on
// repart d'un `extra` vide, sinon l'appelant la ré-empilerait avec
@@ -1925,7 +2344,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// mentir sur ce qui s'est passé — mais l'historique du modèle garde la
// trace ET la correction, donc la vraie réponse suit immédiatement.
if snippet := textualToolCallSnippet(assistantContent.String()); snippet != "" &&
patternRetries < maxPatternRetries && len(tools) > 0 && !disableTools {
patternRetries < maxPatternRetries && callsOn {
patternRetries++
bad := Message{Role: "assistant", Content: assistantContent.String()}
fix := Message{Role: "user", Content: retryCorrective(snippet)}
@@ -1940,20 +2359,46 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// Filet de sécurité (le vrai fix est le prompt court, voir baseSystemPrompt) :
// si un modèle « pense sans agir » malgré tout, on le relance avec une
// consigne impérative au lieu d'afficher « pas de réponse ».
if len(tools) > 0 && !disableTools && nudgeCount < maxNudges {
//
// Sauf si la complétion a été COUPÉE par une fenêtre pleine
// (« length » au-delà du seuil de compaction) : le modèle n'a pas
// « pensé sans agir », il n'a plus eu de place. Lui dire d'arrêter de
// raisonner raccourcirait sa réflexion et alourdirait encore le
// contexte ; on compacte et on rejoue l'étape, une fois.
if finishReason == "length" && !ep.External && lengthReplays < 1 &&
compactWouldTrigger(messages, stats.PromptTokensTotal+stats.GenTokens) {
lengthReplays++
yes, no := true, false
cb(StreamEvent{Compacting: &yes})
c, changed := compactMessages(ctx, messages, caps)
cb(StreamEvent{Compacting: &no})
logCompact("fenêtre-pleine", stats.PromptTokensTotal+stats.GenTokens, messages, c, changed)
if changed {
cb(StreamEvent{DropReasoning: true})
messages = c
extra = nil
cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
continue
}
}
if callsOn && nudgeCount < maxNudges {
nudgeCount++
logCtx("relance « pensé sans agir » n°%d (finish=%s)", nudgeCount, finishReason)
// Le raisonnement de ce tour avorté ne mène à rien : on demande à
// l'UI de l'effacer avant de relancer, pour ne pas afficher deux
// blocs de réflexion successifs.
cb(StreamEvent{DropReasoning: true})
nudge := "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act."
nudge := thinkNudgeFirst
if nudgeCount > 1 {
// Le premier nudge n'a pas suffi : le modèle re-décrit le même
// plan sans l'exécuter. Second nudge plus impératif, où on lui
// interdit explicitement de re-raisonner.
nudge = "You are stuck re-describing the same plan without executing it. Stop reasoning. In your NEXT message, either call ONE tool right now, or write your final answer in plain text using only what you already know — no more planning, no more thinking, act or answer this instant."
nudge = thinkNudgeStuck
}
messages = append(messages, Message{Role: "user", Content: nudge})
// Persisté tel qu'envoyé (appendNudge) : au tour suivant, le modèle
// relit la consigne qui a produit sa réponse, et le préfixe en
// cache tient jusqu'à elle.
messages, extra = appendNudge(messages, extra, nudge)
continue
}
cb(StreamEvent{Content: "_(le modèle n'a pas produit de réponse — finish: " + finishReason + ")_"})
+158
View File
@@ -0,0 +1,158 @@
package loki
// llm_injected.go — messages que LOKI glisse dans le fil (rappels, relances),
// et leur place dans l'historique.
//
// Ces rappels partaient autrefois dans la vue du modèle SANS entrer dans
// l'historique persistant : au tour suivant, llama-server recevait un fil où ils
// avaient disparu, le préfixe divergeait juste avant eux et tout ce qui suivait
// (souvent une longue boucle d'outils) était recalculé. Le modèle relisait en
// prime une histoire qu'il n'avait jamais vue : des résultats d'outils suivis
// d'une réponse finale, sans la consigne qui l'avait provoquée.
//
// Persistés tels qu'envoyés, ils ont un rôle `user` sans être des demandes de
// l'utilisateur : tout ce qui lit « le dernier message user » comme la tâche en
// cours (compaction, vérificateur, export, titre) doit donc les reconnaître et
// les sauter — c'est isLokiInjected.
import "strings"
// lokiNotePrefix ouvre les rappels de loki (budget d'outils, consigne de la
// relance après un 500) : une marque stable, lisible par le modèle comme une
// note du système et non comme la parole de l'utilisateur.
const lokiNotePrefix = "[system] "
// Relances « pensé sans agir » (voir runChat). Gardées en constantes : leur
// texte exact sert aussi à les reconnaître dans l'historique.
const (
thinkNudgeFirst = "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act."
thinkNudgeStuck = "You are stuck re-describing the same plan without executing it. Stop reasoning. In your NEXT message, either call ONE tool right now, or write your final answer in plain text using only what you already know — no more planning, no more thinking, act or answer this instant."
)
// retryCorrectiveLead ouvre la consigne de relance « appel écrit en texte »
// (code_retry.go), déjà persistée avant ce fichier.
const retryCorrectiveLead = "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed:\n"
// toolsOffHint : consigne de la relance après un 500 (appel d'outil que
// llama.cpp n'a pas su parser). Jamais persistée : elle ne vaut que pour la
// relance qui suit.
const toolsOffHint = "Do not call any more tools. Answer now, directly, in the user's language, using only the information already gathered."
// budgetNudgeEnds : fins des trois rappels de budget (llm_budget.go). Le seul
// préfixe « [system] » ne suffit pas à les reconnaître : un utilisateur peut
// très bien coller un texte qui commence ainsi, et sa demande serait alors
// sautée par la compaction, le vérificateur ou le titre. Le test
// TestIsLokiInjected tient ces fins en phase avec budgetNudge.
var budgetNudgeEnds = []string{
"unless exactly one specific call is genuinely still missing.",
"say so in the answer instead of investigating further.",
"Write your final answer in this message. Do not call another tool.",
}
// isLokiInjected dit si un message `user` vient de loki et non de
// l'utilisateur.
func isLokiInjected(m Message) bool {
if m.Role != "user" {
return false
}
s, ok := m.Content.(string)
if !ok {
return false
}
if s == thinkNudgeFirst || s == thinkNudgeStuck || s == lokiNotePrefix+toolsOffHint ||
strings.HasPrefix(s, retryCorrectiveLead) {
return true
}
if !strings.HasPrefix(s, lokiNotePrefix) {
return false
}
for _, end := range budgetNudgeEnds {
if strings.HasSuffix(s, end) {
return true
}
}
return false
}
// dropStrayNudges retire de l'historique à persister les rappels de loki
// restés en porte-à-faux : en toute fin (le tour a été arrêté ou a échoué avant
// que le modèle y réponde), ou collés à un autre message `user` (un ajout en
// cours de réponse arrivé juste derrière, ou un rappel éphémère qu'une
// compaction en cours de tour a fait entrer dans l'historique). Gardés, ils
// laisseraient deux `user` d'affilée au tour suivant — exception à chaque tour
// sur les gabarits à alternance stricte (voir appendNudge). Retirés, on
// retrouve l'historique d'avant leur persistance ; le cache ne perd que ce
// qui suivait le rappel, comme avant. Renvoie msgs tel quel s'il n'y a rien à
// retirer, une copie sinon.
func dropStrayNudges(msgs []Message) []Message {
for {
drop := -1
for i, m := range msgs {
if !isLokiInjected(m) {
continue
}
if i == len(msgs)-1 || msgs[i+1].Role == "user" || (i > 0 && msgs[i-1].Role == "user") {
drop = i
break
}
}
if drop < 0 {
return msgs
}
msgs = append(append([]Message(nil), msgs[:drop]...), msgs[drop+1:]...)
}
}
// appendNudge ajoute un rappel de loki à la vue du modèle et, quand c'est sans
// risque, à l'historique persistant — exactement tel qu'envoyé, pour que le
// tour suivant rejoue les mêmes octets et retrouve son préfixe en cache.
//
// Sauf s'il suit directement un autre message `user` (le message de
// l'utilisateur à l'étape 0, un autre rappel, un ajout en cours de réponse) :
// les gabarits qui exigent l'alternance stricte user/assistant (Gemma,
// Mistral) lèvent une exception sur deux `user` consécutifs. Éphémère, cette
// paire ne coûte qu'une étape ; persistée, elle ferait échouer chaque tour
// suivant de la conversation. Le rappel reste alors éphémère, comme avant.
func appendNudge(messages, extra []Message, text string) ([]Message, []Message) {
m := Message{Role: "user", Content: text}
afterUser := len(messages) > 0 && messages[len(messages)-1].Role == "user"
messages = append(messages, m)
if !afterUser {
extra = append(extra, m)
}
return messages, extra
}
// withTrailingHint renvoie une COPIE de msgs dont le dernier message porte en
// plus la consigne hint, à la fin. Sert à la relance après un 500 : poser la
// consigne en tête (steerSystem) modifiait le message système, donc tout le
// prompt était à recalculer ; à la fin, le préfixe déjà en cache reste valide.
//
// Ajoutée au contenu du dernier message (résultat d'outil ou message user)
// plutôt qu'en nouveau message : deux `user` d'affilée cassent les gabarits
// stricts, et sur Qwen3.5 un nouveau message user devient la « dernière
// question » et fait re-rendre toute la boucle du tour. Les messages ne sont
// jamais modifiés en place : ils appartiennent à l'historique.
func withTrailingHint(msgs []Message, hint string) []Message {
note := "\n\n" + lokiNotePrefix + hint
out := append([]Message(nil), msgs...)
if n := len(out); n > 0 && (out[n-1].Role == "tool" || out[n-1].Role == "user") {
last := out[n-1]
switch c := last.Content.(type) {
case string:
last.Content = c + note
out[n-1] = last
return out
case []any:
last.Content = append(append([]any(nil), c...), map[string]any{"type": "text", "text": note})
out[n-1] = last
return out
case []map[string]any:
last.Content = append(append([]map[string]any(nil), c...), map[string]any{"type": "text", "text": note})
out[n-1] = last
return out
}
}
// Dernier message d'une autre forme (cas théorique) : un message à part.
return append(out, Message{Role: "user", Content: lokiNotePrefix + hint})
}
+488
View File
@@ -0,0 +1,488 @@
package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strings"
"sync"
"testing"
)
const sseStop = `data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"
const sseGlobCall = `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"g1","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*.go\"}"}}]}}]}` + "\n\n" +
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\ndata: [DONE]\n\n"
// scriptedServer répond à la n-ième requête par steps[n] (le dernier se répète)
// et garde le corps JSON de chaque requête. Un step "500" renvoie une erreur
// 500, comme llama.cpp sur un appel d'outil qu'il ne sait pas parser.
func scriptedServer(t *testing.T, steps ...string) func() []map[string]any {
t.Helper()
var mu sync.Mutex
var bodies []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
bodies = append(bodies, body)
n := len(bodies) - 1
mu.Unlock()
step := steps[min(n, len(steps)-1)]
switch step {
case "500":
http.Error(w, "Failed to parse tool call", http.StatusInternalServerError)
return
case "400":
http.Error(w, "Unsupported param: tool_choice", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
_, _ = w.Write([]byte(step))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
return func() []map[string]any {
mu.Lock()
defer mu.Unlock()
return append([]map[string]any(nil), bodies...)
}
}
// reqMessages relit les messages d'une requête enregistrée.
func reqMessages(t *testing.T, body map[string]any) []Message {
t.Helper()
raw, _ := json.Marshal(body["messages"])
var out []Message
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatal(err)
}
return out
}
func TestIsLokiInjected(t *testing.T) {
cases := []struct {
name string
m Message
want bool
}{
{"budget 1", um(budgetNudge(24, 24, 0)), true},
{"budget 2", um(budgetNudge(48, 24, 1)), true},
{"budget 3", um(budgetNudge(72, 24, 2)), true},
{"budget suivants", um(budgetNudge(150, 24, 5)), true},
{"consigne du 500 en message à part", um(lokiNotePrefix + toolsOffHint), true},
{"demande qui commence comme un rappel", um(lokiNotePrefix + "réécris ce prompt système"), false},
{"pensé sans agir", um(thinkNudgeFirst), true},
{"pensé sans agir, bis", um(thinkNudgeStuck), true},
{"appel écrit en texte", um(retryCorrective("<tool_call>x")), true},
{"vraie demande", um("cherche les horaires"), false},
{"demande qui cite un rappel", um("pourquoi « " + thinkNudgeFirst + " » ?"), false},
{"assistant", am(lokiNotePrefix + "x"), false},
{"user multimodal", Message{Role: "user", Content: []any{map[string]any{"type": "text", "text": lokiNotePrefix}}}, false},
}
for _, c := range cases {
if got := isLokiInjected(c.m); got != c.want {
t.Errorf("%s : isLokiInjected = %v, attendu %v", c.name, got, c.want)
}
}
}
// Le rappel entre dans l'historique tel qu'envoyé, sauf derrière un autre
// message user (gabarits à alternance stricte) où il reste éphémère.
func TestAppendNudge(t *testing.T) {
cases := []struct {
name string
before []Message
persisted bool
}{
{"après un résultat d'outil", []Message{um("q"), atc("glob"), tm("r")}, true},
{"après un assistant", []Message{um("q"), am("a")}, true},
{"après la demande", []Message{um("q")}, false},
{"après un autre rappel", []Message{um("q"), atc("glob"), tm("r"), um(thinkNudgeFirst)}, false},
{"fil vide", nil, true},
}
for _, c := range cases {
msgs, extra := appendNudge(append([]Message(nil), c.before...), nil, thinkNudgeFirst)
if len(msgs) != len(c.before)+1 || msgText(msgs[len(msgs)-1]) != thinkNudgeFirst {
t.Errorf("%s : rappel absent de la vue du modèle : %+v", c.name, msgs)
}
if got := len(extra) == 1; got != c.persisted {
t.Errorf("%s : persisté = %v, attendu %v", c.name, got, c.persisted)
}
if c.persisted && !reflect.DeepEqual(extra[0], msgs[len(msgs)-1]) {
t.Errorf("%s : persisté %+v ≠ envoyé %+v", c.name, extra[0], msgs[len(msgs)-1])
}
}
}
func TestWithTrailingHint(t *testing.T) {
note := "\n\n" + lokiNotePrefix + "H"
cases := []struct {
name string
in []Message
want Message // dernier message attendu
grow bool // un message ajouté plutôt qu'enrichi
}{
{"résultat d'outil", []Message{um("q"), atc("glob"), tm("r")}, Message{Role: "tool", ToolCallID: "c1", Content: "r" + note}, false},
{"message user", []Message{um("q")}, um("q" + note), false},
{"parties []any", []Message{{Role: "user", Content: []any{map[string]any{"type": "text", "text": "q"}}}},
Message{Role: "user", Content: []any{map[string]any{"type": "text", "text": "q"}, map[string]any{"type": "text", "text": note}}}, false},
{"parties []map", []Message{{Role: "user", Content: []map[string]any{{"type": "text", "text": "q"}}}},
Message{Role: "user", Content: []map[string]any{{"type": "text", "text": "q"}, {"type": "text", "text": note}}}, false},
{"assistant en dernier", []Message{um("q"), am("a")}, um(lokiNotePrefix + "H"), true},
}
for _, c := range cases {
orig, _ := json.Marshal(c.in)
out := withTrailingHint(c.in, "H")
if after, _ := json.Marshal(c.in); string(after) != string(orig) {
t.Errorf("%s : l'historique d'origine a été modifié", c.name)
}
wantLen := len(c.in)
if c.grow {
wantLen++
}
if len(out) != wantLen {
t.Fatalf("%s : %d messages, attendu %d", c.name, len(out), wantLen)
}
if !reflect.DeepEqual(out[len(out)-1], c.want) {
t.Errorf("%s : dernier message %+v, attendu %+v", c.name, out[len(out)-1], c.want)
}
if !reflect.DeepEqual(out[:len(c.in)-1], c.in[:len(c.in)-1]) {
t.Errorf("%s : le début du fil a changé", c.name)
}
}
}
// Les lecteurs de « la demande de l'utilisateur » sautent les rappels.
func TestRappelsPasPrisPourLaDemande(t *testing.T) {
msgs := []Message{um("corrige le build"), atc("bash"), tm("ok"), um(budgetNudge(24, 24, 0)), am("fait"),
um("et les tests"), atc("bash"), tm("ok"), um(thinkNudgeFirst), am("fait")}
c := &Conversation{Messages: msgs}
if got := c.lastUserText(); got != "et les tests" {
t.Errorf("lastUserText = %q", got)
}
if got := lastTurnsMessages(msgs, 1); len(got) == 0 || msgText(got[0]) != "et les tests" {
t.Errorf("dernier échange = %+v", got)
}
if got := lastTurnsMessages(msgs, 2); len(got) != len(msgs) {
t.Errorf("deux échanges : %d messages, attendu %d", len(got), len(msgs))
}
if got := convSummary([]Message{um(thinkNudgeFirst), um("bonjour")}); got != "bonjour" {
t.Errorf("titre = %q", got)
}
if tr := renderTranscript([]Message{um(thinkNudgeFirst)}); !strings.HasPrefix(tr, "System: ") {
t.Errorf("rappel présenté au résumeur comme parole de l'utilisateur : %q", tr)
}
}
// Compaction après un long tour ponctué de rappels : la VRAIE demande est
// réinjectée, qu'un rappel traîne dans le torse ou dans la queue.
func TestCompactionReinjecteLaVraieDemandePasLeRappel(t *testing.T) {
testHome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- pages lues : horaires trouvés pour le matin, reste l'après-midi"}}}})
}))
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("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
}
torsoNudge, tailNudge := budgetNudge(24, 24, 0), budgetNudge(48, 24, 1)
msgs := []Message{um("première question"), am("ok"), um("cherche les horaires du train pour Lyon")}
for i := 0; i < 12; i++ {
msgs = append(msgs, atc("web_read"), page(i))
if i == 3 {
msgs = append(msgs, um(torsoNudge))
}
}
msgs = append(msgs, um(tailNudge), atc("web_read"), page(13))
_, tailStart := compactBounds(msgs, int(float64(estimateTokens(msgs))*compactTailFrac))
if msgText(msgs[len(msgs)-3]) != tailNudge || tailStart > len(msgs)-3 {
t.Fatalf("montage : le rappel de queue n'est pas dans la queue (tailStart=%d)", tailStart)
}
out, changed := compactMessages(t.Context(), msgs, Caps{})
if !changed {
t.Fatal("pas compacté")
}
count := func(s string) int {
n := 0
for _, m := range out {
if m.Role == "user" && msgText(m) == s {
n++
}
}
return n
}
if n := count("cherche les horaires du train pour Lyon"); n != 1 {
t.Fatalf("demande en cours présente %d fois après compaction, attendu 1", n)
}
if count(torsoNudge) != 0 {
t.Fatal("le rappel du torse a été réinjecté comme demande en cours")
}
if count(tailNudge) != 1 {
t.Fatal("le rappel de la queue a disparu")
}
assertAlternance(t, out)
}
// assertAlternance : jamais deux `user` d'affilée (gabarits stricts).
func assertAlternance(t *testing.T, msgs []Message) {
t.Helper()
for i := 1; i < len(msgs); i++ {
if msgs[i].Role == "user" && msgs[i-1].Role == "user" {
t.Fatalf("deux user d'affilée en %d : %q puis %q", i, msgText(msgs[i-1]), msgText(msgs[i]))
}
}
}
// La queue protégée ne commence jamais sur un rappel : la vraie demande,
// réinjectée juste devant elle, lui serait collée (deux user d'affilée).
func TestCompactBoundsPasSurUnRappel(t *testing.T) {
nudge := um(budgetNudge(24, 24, 0))
msgs := []Message{um("cherche"), atc("web_read"), tm(strings.Repeat("x", 800)), nudge, atc("web_read"), tm("court")}
budget := msgTokens(msgs[3]) + msgTokens(msgs[4]) + msgTokens(msgs[5])
_, tailStart := compactBounds(msgs, budget)
if tailStart != 1 {
t.Fatalf("tailStart = %d, attendu 1 (l'appel d'outil avant le rappel)", tailStart)
}
// Sans rappel, la frontière reste celle d'avant.
plain := []Message{um("cherche"), atc("web_read"), tm(strings.Repeat("x", 800)), um("et ensuite"), atc("web_read"), tm("court")}
if _, ts := compactBounds(plain, msgTokens(plain[3])+msgTokens(plain[4])+msgTokens(plain[5])); ts != 3 {
t.Fatalf("frontière sans rappel déplacée : %d", ts)
}
}
// Ce qui est persisté en fin de tour ne garde aucun rappel en porte-à-faux.
func TestDropStrayNudges(t *testing.T) {
n := um(thinkNudgeFirst)
cases := []struct {
name string
in []Message
want []Message
}{
{"rappel répondu", []Message{um("q"), atc("glob"), tm("r"), n, am("fini")}, nil},
{"rappel resté sans réponse (stop)", []Message{um("q"), atc("glob"), tm("r"), n},
[]Message{um("q"), atc("glob"), tm("r")}},
{"ajout en cours de réponse juste derrière", []Message{um("q"), atc("glob"), tm("r"), n, um("et aussi"), am("fini")},
[]Message{um("q"), atc("glob"), tm("r"), um("et aussi"), am("fini")}},
{"rappel collé derrière la demande", []Message{um("q"), n, am("fini")}, []Message{um("q"), am("fini")}},
{"correctif sans réponse", []Message{um("q"), am("<tool_call>"), um(retryCorrective("<tool_call>"))},
[]Message{um("q"), am("<tool_call>")}},
{"deux rappels de suite en fin", []Message{um("q"), atc("glob"), tm("r"), n, um(thinkNudgeStuck)},
[]Message{um("q"), atc("glob"), tm("r")}},
{"vraie demande en fin", []Message{um("q"), am("a"), um("suite")}, nil},
}
for _, c := range cases {
orig, _ := json.Marshal(c.in)
got := dropStrayNudges(c.in)
if after, _ := json.Marshal(c.in); string(after) != string(orig) {
t.Errorf("%s : l'entrée a été modifiée", c.name)
}
want := c.want
if want == nil {
want = c.in
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s :\n got %+v\n want %+v", c.name, got, want)
}
assertAlternance(t, got)
}
}
// La réduction forcée coupe aux vraies demandes ; un rappel ne sert de coupe
// qu'à défaut.
func TestShrinkCoupeAuxVraiesDemandes(t *testing.T) {
testHome(t)
if err := SetConfigKey("CTX", "4096"); err != nil {
t.Fatal(err)
}
big := am(strings.Repeat("raisonnement long ", 400))
sys := Message{Role: "system", Content: "SYS"}
out, changed := shrinkToFit([]Message{sys, um("demande A"), big, um(thinkNudgeFirst), big, um("demande B"), am("ok")}, 0)
if !changed || len(out) < 2 || msgText(out[1]) != "demande B" {
t.Fatalf("coupe attendue à « demande B » : %+v", out)
}
out, changed = shrinkToFit([]Message{sys, um("demande A"), big, um(thinkNudgeFirst), big}, 0)
if !changed || len(out) < 2 || msgText(out[1]) != thinkNudgeFirst {
t.Fatalf("sans autre demande, la coupe doit retomber sur le rappel : %+v", out)
}
}
// Le rappel de budget part au moteur ET dans l'historique renvoyé : le tour
// suivant rejoue les mêmes octets.
func TestRappelDeBudgetPersisteTelQuEnvoye(t *testing.T) {
withWorkspace(t)
if err := SetConfigKey("AGENT_BUDGET", "1"); err != nil {
t.Fatal(err)
}
reqs := scriptedServer(t, sseGlobCall, sseChunk("fini")+sseStop)
extra, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
if err != nil {
t.Fatal(err)
}
bodies := reqs()
if len(bodies) != 2 {
t.Fatalf("%d requêtes, attendu 2", len(bodies))
}
sent := reqMessages(t, bodies[1])
last := sent[len(sent)-1]
if !strings.HasPrefix(msgText(last), lokiNotePrefix) {
t.Fatalf("rappel absent de la requête : %+v", last)
}
if len(extra) != 3 || !reflect.DeepEqual(extra[2], last) {
t.Fatalf("historique renvoyé %+v : le rappel doit y être tel qu'envoyé", extra)
}
}
// « Pensé sans agir » : persisté après un résultat d'outil, éphémère juste
// après le message de l'utilisateur (deux user d'affilée).
func TestRelancePenseSansAgirPersistee(t *testing.T) {
withWorkspace(t)
reqs := scriptedServer(t, sseGlobCall, sseStop, sseChunk("fini")+sseStop)
extra, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
if err != nil {
t.Fatal(err)
}
if n := len(reqs()); n != 3 {
t.Fatalf("%d requêtes, attendu 3", n)
}
if len(extra) != 3 || extra[2].Role != "user" || msgText(extra[2]) != thinkNudgeFirst {
t.Fatalf("relance non persistée après l'outil : %+v", extra)
}
reqs = scriptedServer(t, sseStop, sseChunk("fini")+sseStop)
extra, err = runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
if err != nil {
t.Fatal(err)
}
if bodies := reqs(); len(bodies) != 2 || msgText(reqMessages(t, bodies[1])[1]) != thinkNudgeFirst {
t.Fatal("la relance doit quand même partir au moteur")
}
if len(extra) != 0 {
t.Fatalf("relance persistée derrière la demande (deux user d'affilée) : %+v", extra)
}
}
// Relance après un 500 (llama-server local) : mêmes outils, même message 0,
// tool_choice none et la consigne au bout du dernier message — qui n'entre
// pas dans l'historique.
func TestRelance500GardeLePrompt(t *testing.T) {
testHome(t)
reqs := scriptedServer(t, "500", sseChunk("voici")+sseStop)
in := []Message{{Role: "system", Content: "SYS"}, um("question")}
extra, err := runChat(t.Context(), in, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
if err != nil {
t.Fatal(err)
}
bodies := reqs()
if len(bodies) != 2 {
t.Fatalf("%d requêtes, attendu 2", len(bodies))
}
first, retry := bodies[0], bodies[1]
if first["tools"] == nil || !reflect.DeepEqual(first["tools"], retry["tools"]) {
t.Fatal("les outils doivent rester identiques dans la relance")
}
if _, ok := first["tool_choice"]; ok {
t.Fatal("tool_choice posé hors relance")
}
if retry["tool_choice"] != "none" {
t.Fatalf("tool_choice = %v, attendu none", retry["tool_choice"])
}
m0, r0 := reqMessages(t, first), reqMessages(t, retry)
if !reflect.DeepEqual(m0[0], r0[0]) {
t.Fatalf("message 0 modifié : %+v → %+v", m0[0], r0[0])
}
if got := msgText(r0[len(r0)-1]); got != "question\n\n"+lokiNotePrefix+toolsOffHint {
t.Fatalf("consigne absente du bout du fil : %q", got)
}
if len(extra) != 0 {
t.Fatalf("la consigne ne doit pas être persistée : %+v", extra)
}
}
// Repli : la relance « none » échoue à son tour (500, ou appel tenté malgré
// tout) → chemin historique, outils retirés et consigne dans le système.
func TestRelance500ReplieSurLeCheminHistorique(t *testing.T) {
leak := `data: {"choices":[{"delta":{"content":"<tool_call>"}}]}` + "\n\n" + sseStop
for name, second := range map[string]string{"second 500": "500", "refus 4xx": "400", "appel en texte": leak,
"appel par le protocole": sseGlobCall, "réponse vide": sseStop} {
t.Run(name, func(t *testing.T) {
testHome(t)
reqs := scriptedServer(t, "500", second, sseChunk("voici")+sseStop)
in := []Message{{Role: "system", Content: "SYS"}, um("question")}
ran := false
if _, err := runChat(t.Context(), in, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
ran = ran || ev.ToolUsed != nil
return true
}); err != nil {
t.Fatal(err)
}
if ran {
t.Fatal("un appel émis sous tool_choice none a été exécuté")
}
bodies := reqs()
if len(bodies) != 3 {
t.Fatalf("%d requêtes, attendu 3", len(bodies))
}
last := bodies[2]
if _, ok := last["tools"]; ok {
t.Fatal("le repli doit retirer les outils")
}
if _, ok := last["tool_choice"]; ok {
t.Fatal("tool_choice ne doit pas survivre au repli")
}
msgs := reqMessages(t, last)
if !strings.Contains(msgText(msgs[0]), toolsOffHint) || strings.Contains(msgText(msgs[len(msgs)-1]), toolsOffHint) {
t.Fatalf("consigne du repli mal placée : %+v", msgs)
}
})
}
}
// Preset externe : la relance garde le chemin historique dès le premier 500.
func TestRelance500ExterneInchangee(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)
n := len(bodies)
mu.Unlock()
if n == 1 {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(sseChunk("voici") + sseStop))
}))
t.Cleanup(srv.Close)
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "gpt-4o-mini", "", "", false))); err != nil {
t.Fatal(err)
}
if _, err := runChat(t.Context(), []Message{um("question")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }); err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if len(bodies) != 2 {
t.Fatalf("%d requêtes, attendu 2", len(bodies))
}
if _, ok := bodies[1]["tools"]; ok {
t.Fatal("preset externe : la relance doit retirer les outils, comme avant")
}
if _, ok := bodies[1]["tool_choice"]; ok {
t.Fatal("preset externe : pas de tool_choice")
}
}
+24
View File
@@ -104,7 +104,31 @@ func oaiHandler() http.Handler {
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
// /slots en lecture seulement : save, restore et erase restent à Loki
// (llm_slots.go).
if slotsWrite(r) {
sendOAIError(w, http.StatusMethodNotAllowed,
"/slots est en lecture seule à travers Loki", "invalid_request_error", "method_not_allowed")
return
}
if strings.HasPrefix(p, "/v1") || p == "/health" || p == "/props" || p == "/metrics" || strings.HasPrefix(p, "/slots") {
// Requête en vol vers le moteur : l'isolation des travaux annexes
// n'efface pas le slot pendant ce temps. Différé : ReverseProxy panique
// (ErrAbortHandler) quand le client coupe en plein flux.
defer engineRequestStart()()
// Complétion d'un client externe : elle prend le slot sous le nez de
// la conversation. Notée pour nommer la perte de cache qui suit.
if r.Method == http.MethodPost && strings.HasPrefix(p, "/v1/") {
// Pendant un benchmark, elle fausserait la mesure et attendrait des
// minutes derrière lui : refus explicite, à retenter.
if benchRunning() {
w.Header().Set("Retry-After", "30")
sendOAIError(w, http.StatusServiceUnavailable,
"benchmark en cours sur ce moteur — réessaie dans un moment", "server_error", "benchmark_running")
return
}
perfNoteForeign()
}
lp.ServeHTTP(w, r)
return
}
+8 -7
View File
@@ -99,10 +99,11 @@ func TestStatutTransitoireEstRejoueEtAboutit(t *testing.T) {
// échecs déterministes ; attendre puis rejouer à l'identique ne ferait que
// retarder l'erreur de plusieurs secondes.
//
// Le tour peut malgré tout repartir UNE fois : le filet de compaction en vol,
// antérieur à ce fichier, retente le tour avec un historique résumé (le 500 est
// souvent un dépassement de contexte). C'est un rejeu SÉMANTIQUE — il change la
// requête et n'attend pas. La preuve qu'aucune reprise réseau n'a eu lieu est
// Le tour peut malgré tout repartir : le filet de compaction en vol, antérieur
// à ce fichier, retente le tour avec un historique résumé (le 500 est souvent
// un dépassement de contexte), et la relance d'un appel d'outil mal formé
// essaie tool_choice « none » puis, refusée elle aussi, le retrait des outils.
// Ce sont des rejeux SÉMANTIQUES — ils changent la requête et n'attendent pas. La preuve qu'aucune reprise réseau n'a eu lieu est
// donc le CHRONO : trois reprises coûteraient au bas mot 0,8 + 1,6 s d'attente.
func TestErreur500NestPasRejoueeParLeReseau(t *testing.T) {
testHome(t)
@@ -119,9 +120,9 @@ func TestErreur500NestPasRejoueeParLeReseau(t *testing.T) {
if elapsed >= llmNetWait {
t.Fatalf("rendu la main en %v : une attente de reprise réseau a eu lieu sur un 500", elapsed)
}
// Borne haute large : ce qui compte est qu'on ne soit pas allé au bout du
// budget réseau (1 + 3 = 4 appels).
if n := calls.Load(); n > 2 {
// Borne haute : l'appel, la relance « none » et le repli sans outils. Trois
// reprises réseau en ajouteraient au moins trois.
if n := calls.Load(); n > 3 {
t.Fatalf("%d appels au moteur sur un 500 : le budget de reprise réseau a été consommé", n)
}
}
+283
View File
@@ -0,0 +1,283 @@
package loki
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// Isolation des travaux annexes dans le cache de prompts.
//
// llama-server tourne en --parallel 1 : un vérificateur, un sous-agent, une
// tâche planifiée ou un bench prennent LE slot de la conversation. Le moteur
// range alors l'état de la conversation dans son cache RAM (exact, octet pour
// octet) et le recharge quand elle revient — à condition qu'il y soit encore.
// Or, à la requête suivante, il sauve d'abord l'état du travail annexe, et pour
// lui faire de la place il évince les entrées les plus anciennes : souvent
// justement la conversation, qui est alors recalculée en entier.
//
// Effacer le slot une fois le travail annexe TERMINÉ (pas entre ses étapes)
// règle ça : un slot vide n'est pas sauvé, et la conversation est rechargée
// depuis la RAM. On ne jette qu'un état qui ne resservirait pas — rien de ce
// que voit le modèle ne change.
//
// Mais un effacement est différé par le moteur tant que le slot travaille, et
// s'exécute ensuite sur ce qui s'y trouve alors : il faut donc être SÛR que le
// slot est à nous. D'où les garde-fous, tous nécessaires :
// - moteur local (jamais une API externe) ;
// - build vanilla ≥ 8660 : avant (PR #20993), un slot vide ne rechargeait pas
// depuis le cache, et effacer aurait forcé un recalcul complet. Build
// inconnu ou fork : on s'abstient ;
// - un seul slot (/props total_slots, pas la clé PARALLEL qu'EXTRA_ARGS peut
// contredire) ;
// - le travail annexe a bien été servi par le moteur : sinon le slot porte
// encore la conversation (erreur avant l'envoi, refus, arrêt immédiat) ;
// - aucune requête de Loki en vol vers le moteur (compteur ci-dessous, tenu
// par le chat, les résumés, le bench et les proxys /v1), et le slot 0 au
// repos d'après /slots ;
// - appel synchrone, borné à 2 s, sous le verrou du compteur : aucune
// requête de Loki ne peut partir entre la vérification et l'effacement.
// slotEraseMinBuild : premier build de llama.cpp qui recharge un slot vide
// depuis le cache de prompts (PR #20993, 50e0ad08fb).
const slotEraseMinBuild = 8660
// engineGate compte les requêtes de Loki en vol vers le moteur local.
// L'effacement prend le verrou pendant sa vérification et son appel : une
// nouvelle requête attend au plus ces deux secondes.
var engineGate struct {
mu sync.Mutex
inflight int
served uint64 // réponses 200 du moteur obtenues par runChat ou le bench
}
// engineServed note qu'une requête de Loki a été acceptée par le moteur local :
// le slot porte désormais SON état. Un travail annexe qui n'en a obtenu aucune
// (erreur avant l'envoi, moteur qui refuse, arrêt immédiat) n'a pas pris le
// slot — il porte encore la conversation, et l'effacer la ferait recalculer.
func engineServed() {
engineGate.mu.Lock()
engineGate.served++
engineGate.mu.Unlock()
}
func engineServedCount() uint64 {
engineGate.mu.Lock()
defer engineGate.mu.Unlock()
return engineGate.served
}
// sideJobs : travaux annexes en cours. Leurs prompts ne sont pas ceux de la
// conversation : noteEnginePrompt ne les retient ni ne les compare.
var sideJobs atomic.Int32
// engineRequestStart marque une requête vers le moteur local ; la fonction
// rendue la clôt (une seule fois, quel que soit le nombre d'appels), une fois
// le corps de la réponse lu.
func engineRequestStart() func() {
engineGate.mu.Lock()
engineGate.inflight++
engineGate.mu.Unlock()
var once sync.Once
return func() {
once.Do(func() {
engineGate.mu.Lock()
engineGate.inflight--
engineGate.mu.Unlock()
})
}
}
// slotsWrite : une action sur /slots (save, restore, erase) plutôt qu'une
// lecture. Les proxys de Loki la refusent : seul Loki, en local, efface — et
// save ou restore écriraient des Gio sur le disque pour un client distant.
func slotsWrite(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, "/slots") && r.Method != http.MethodGet && r.Method != http.MethodHead
}
// slotIsolator parle au moteur local ; séparé pour les tests.
type slotIsolator struct {
base string // http://localhost:<port>
auth func(set func(k, v string))
client *http.Client
}
var reBuildInfo = regexp.MustCompile(`^b(\d+)`)
// get lit un JSON du moteur, borné à 2 s.
func (s slotIsolator) get(ctx context.Context, path string, out any) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base+path, nil)
if err != nil {
return err
}
s.auth(req.Header.Set)
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s : %s", path, resp.Status)
}
return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out)
}
// eraseIfIdle efface le slot 0 si, et seulement si, tous les garde-fous
// passent. erased=false sans erreur : on s'est abstenu, sans dommage.
func (s slotIsolator) eraseIfIdle(ctx context.Context) (erased bool, err error) {
var props struct {
BuildInfo string `json:"build_info"`
TotalSlots int `json:"total_slots"`
}
if err := s.get(ctx, "/props", &props); err != nil {
return false, err
}
build := 0
if m := reBuildInfo.FindStringSubmatch(props.BuildInfo); m != nil {
build, _ = strconv.Atoi(m[1])
}
if build < slotEraseMinBuild || props.TotalSlots != 1 {
return false, nil
}
engineGate.mu.Lock()
defer engineGate.mu.Unlock()
if engineGate.inflight > 0 {
return false, nil
}
var slots []struct {
ID int `json:"id"`
IsProcessing bool `json:"is_processing"`
}
if err := s.get(ctx, "/slots", &slots); err != nil {
return false, err
}
for _, sl := range slots {
if sl.ID == 0 && sl.IsProcessing {
return false, nil
}
}
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.base+"/slots/0?action=erase", nil)
if err != nil {
return false, err
}
s.auth(req.Header.Set)
resp, err := s.client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
return false, fmt.Errorf("effacement du slot : %s %s", resp.Status, strings.TrimSpace(string(b)))
}
return true, nil
}
var slotEraseLogOnce sync.Once
// engineSideJob encadre un travail annexe (sous-agent, vérification, tâche,
// bench) : à appeler AVANT, et la fonction rendue APRÈS — d'habitude
// « defer engineSideJob()() ». Elle efface le slot si c'est sans risque, et
// arme la vérification du cache sur la requête suivante (noteEnginePrompt).
func engineSideJob() func() {
before, servedAt := lastEnginePrompt.Load(), engineServedCount()
sideJobs.Add(1)
return func() {
sideJobs.Add(-1)
finishSideJob(before, servedAt, eraseLocalSlot)
}
}
// finishSideJob : la fin d'un travail annexe, séparée de ses appels au moteur
// pour les tests. Rien n'est effacé si le moteur n'a servi aucune requête de
// Loki depuis le début du travail : le slot porte alors toujours la
// conversation (ou l'état d'un client qu'on ne connaît pas).
func finishSideJob(before int64, servedAt uint64, erase func() (bool, error)) {
if engineServedCount() == servedAt {
return
}
erased, err := erase()
if err != nil {
// Moteur sans --slot-save-path (501), ou /slots coupé : l'erreur est
// sans conséquence et se répéterait à chaque travail annexe.
slotEraseLogOnce.Do(func() {
fmt.Fprintf(os.Stderr, "[cache] isolation des travaux annexes indisponible : %v\n", err)
})
}
if erased && before > 0 {
slotHintPending.Store(before)
}
}
// eraseLocalSlot efface le slot 0 du moteur local si le preset actif s'y prête
// et que tous les garde-fous d'eraseIfIdle passent.
func eraseLocalSlot() (bool, error) {
ep := resolveChatEndpoint()
if ep.External {
return false, nil
}
cfg := ReadConfig()
argEnv := map[string]string{}
for _, k := range cacheArgEnv {
argEnv[k] = os.Getenv(k)
}
if !cacheIsolationOn(cfg) || cacheRAMOff(cfg, splitArgs(cfg["EXTRA_ARGS"]), argEnv) {
return false, nil
}
iso := slotIsolator{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: ep.auth, client: http.DefaultClient}
return iso.eraseIfIdle(context.Background())
}
// Vérification du cache après un effacement. Le moteur dit, dans l'usage de
// chaque réponse, combien de jetons du prompt il a repris du cache
// (prompt_tokens_details.cached_tokens). Si la conversation revient après un
// travail annexe avec bien moins que ce qu'elle avait, son état n'a pas tenu
// dans le cache RAM : c'est CACHE_RAM qu'il faut relever.
var (
lastEnginePrompt atomic.Int64 // prompt_tokens de la dernière réponse locale
slotHintPending atomic.Int64 // prompt d'avant le travail annexe effacé ; 0 = rien à vérifier
slotHintShown atomic.Bool // le conseil n'apparaît qu'une fois par lancement
)
// slotHintMinTokens : en dessous, un recalcul coûte trop peu pour en parler.
const slotHintMinTokens = 4096
// noteEnginePrompt enregistre l'usage d'une réponse du moteur local et rend le
// conseil à afficher, s'il y a lieu (une seule fois par lancement ; le journal,
// lui, le note à chaque fois).
func noteEnginePrompt(total, cached int, hasCached bool) string {
// Prompt d'un travail annexe : ni la conversation à comparer, ni la taille
// à retenir pour le prochain (deux vérifications de suite, par exemple).
if sideJobs.Load() > 0 {
return ""
}
if total > 0 {
lastEnginePrompt.Store(int64(total))
}
before := slotHintPending.Swap(0)
// Un prompt plus court que celui d'avant n'est pas la suite de la même
// conversation (nouvelle discussion, autre tâche) : rien à comparer.
if !hasCached || before < slotHintMinTokens || int64(total) < before || int64(cached) >= before/2 {
return ""
}
fmt.Fprintf(os.Stderr, "[cache] conversation recalculée après un travail annexe : %d jetons repris du cache sur %d\n",
cached, before)
if slotHintShown.Swap(true) {
return ""
}
return fmt.Sprintf("Conversation recalculée après un travail annexe (%d jetons repris du cache sur %d) : "+
"le cache de prompts en RAM est trop petit — augmente CACHE_RAM dans le preset.", cached, before)
}
+224
View File
@@ -0,0 +1,224 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// fakeEngine : un llama-server réduit à /props, /slots et l'effacement.
type fakeEngine struct {
build string
totalSlots int
processing bool
eraseCode int
mu sync.Mutex
erases []*http.Request
}
func (f *fakeEngine) server(t *testing.T) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/props":
_ = json.NewEncoder(w).Encode(map[string]any{"build_info": f.build, "total_slots": f.totalSlots})
case r.Method == http.MethodGet && r.URL.Path == "/slots":
_ = json.NewEncoder(w).Encode([]map[string]any{{"id": 0, "is_processing": f.processing}})
case strings.HasPrefix(r.URL.Path, "/slots/"):
f.mu.Lock()
f.erases = append(f.erases, r)
f.mu.Unlock()
w.WriteHeader(f.eraseCode)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv
}
func (f *fakeEngine) isolator(srv *httptest.Server) slotIsolator {
ep := chatEndpoint{Key: "sk-local"}
return slotIsolator{base: srv.URL, auth: ep.auth, client: srv.Client()}
}
func TestSlotEraseAppel(t *testing.T) {
f := &fakeEngine{build: "b8700-50e0ad08", totalSlots: 1, eraseCode: 200}
srv := f.server(t)
erased, err := f.isolator(srv).eraseIfIdle(context.Background())
if err != nil || !erased {
t.Fatalf("eraseIfIdle = %v, %v", erased, err)
}
if len(f.erases) != 1 {
t.Fatalf("%d effacements", len(f.erases))
}
r := f.erases[0]
if r.Method != http.MethodPost || r.URL.Path != "/slots/0" || r.URL.Query().Get("action") != "erase" {
t.Fatalf("requête %s %s", r.Method, r.URL)
}
if got := r.Header.Get("Authorization"); got != "Bearer sk-local" {
t.Fatalf("Authorization = %q", got)
}
}
// Moteur sans --slot-save-path : 501, toléré.
func TestSlotErase501Tolere(t *testing.T) {
f := &fakeEngine{build: "b10678-abc", totalSlots: 1, eraseCode: http.StatusNotImplemented}
erased, err := f.isolator(f.server(t)).eraseIfIdle(context.Background())
if erased || err == nil {
t.Fatalf("501 : eraseIfIdle = %v, %v", erased, err)
}
}
// Chaque garde-fou suffit à empêcher l'effacement — sans erreur : s'abstenir
// est le comportement normal.
func TestSlotEraseGardeFous(t *testing.T) {
cases := []struct {
name string
build string
slots int
processing bool
busy bool
}{
{name: "build avant le rechargement d'un slot vide", build: "b8640-abc", slots: 1},
{name: "build inconnu (fork)", build: "", slots: 1},
{name: "plusieurs slots", build: "b9000-abc", slots: 2},
{name: "slot 0 au travail", build: "b9000-abc", slots: 1, processing: true},
{name: "requête de Loki en vol (proxy /v1, tour de chat)", build: "b9000-abc", slots: 1, busy: true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f := &fakeEngine{build: c.build, totalSlots: c.slots, processing: c.processing, eraseCode: 200}
srv := f.server(t)
if c.busy {
done := engineRequestStart()
defer done()
}
erased, err := f.isolator(srv).eraseIfIdle(context.Background())
if erased || err != nil || len(f.erases) != 0 {
t.Fatalf("eraseIfIdle = %v, %v ; %d effacements", erased, err, len(f.erases))
}
})
}
}
// La fonction de fin d'une requête ne décompte qu'une fois, même rappelée
// (runChat la rappelle sur plusieurs chemins de sortie).
func TestEngineRequestStartIdempotent(t *testing.T) {
done := engineRequestStart()
done()
done()
engineGate.mu.Lock()
n := engineGate.inflight
engineGate.mu.Unlock()
if n != 0 {
t.Fatalf("inflight = %d", n)
}
}
func TestNoteEnginePrompt(t *testing.T) {
slotHintShown.Store(false)
slotHintPending.Store(0)
// Rien d'armé : jamais de conseil.
if h := noteEnginePrompt(30000, 100, true); h != "" {
t.Fatalf("sans travail annexe : %q", h)
}
if lastEnginePrompt.Load() != 30000 {
t.Fatal("dernier prompt non retenu")
}
// Conversation rechargée du cache : rien à dire.
slotHintPending.Store(30000)
if h := noteEnginePrompt(30500, 30000, true); h != "" {
t.Fatalf("cache tenu : %q", h)
}
// Moteur sans cached_tokens : on ne sait pas, on se tait.
slotHintPending.Store(30000)
if h := noteEnginePrompt(30500, 0, false); h != "" {
t.Fatalf("sans cached_tokens : %q", h)
}
// Prompt plus court : une autre conversation, pas un recalcul.
slotHintPending.Store(30000)
if h := noteEnginePrompt(5000, 1000, true); h != "" {
t.Fatalf("autre conversation : %q", h)
}
// Recalcul complet : le conseil, une seule fois.
slotHintPending.Store(30000)
if h := noteEnginePrompt(30500, 2000, true); !strings.Contains(h, "CACHE_RAM") {
t.Fatalf("recalcul : %q", h)
}
slotHintPending.Store(30000)
if h := noteEnginePrompt(30500, 2000, true); h != "" {
t.Fatalf("second conseil : %q", h)
}
}
// Les proxys de Loki ne laissent passer que les lectures de /slots.
func TestProxysRefusentLesActionsSlots(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
t.Setenv("LOKI_LINK_ALLOW_OAI", "1")
for name, h := range map[string]http.Handler{
"oaiHandler": oaiHandler(),
"newLinkHandler": newLinkHandler(http.NewServeMux()),
} {
for _, action := range []string{"save", "restore", "erase"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/slots/0?action="+action, strings.NewReader(`{"filename":"x.bin"}`))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s : POST /slots/0?action=%s → %d, attendu 405", name, action, rec.Code)
}
}
}
}
// Un travail annexe que le moteur n'a jamais servi (erreur avant l'envoi,
// refus, arrêt immédiat) n'a pas pris le slot : il porte encore la
// conversation, on n'efface rien. Servi, on efface et on arme la vérification.
func TestFinishSideJobSeulementApresUneRequeteServie(t *testing.T) {
slotHintPending.Store(0)
calls := 0
erase := func() (bool, error) { calls++; return true, nil }
finishSideJob(30000, engineServedCount(), erase)
if calls != 0 || slotHintPending.Load() != 0 {
t.Fatalf("sans requête servie : %d effacements, vérification %d", calls, slotHintPending.Load())
}
at := engineServedCount()
engineServed()
finishSideJob(30000, at, erase)
if calls != 1 || slotHintPending.Load() != 30000 {
t.Fatalf("après une requête servie : %d effacements, vérification %d", calls, slotHintPending.Load())
}
slotHintPending.Store(0)
// Abstention ou échec : rien d'armé.
at = engineServedCount()
engineServed()
finishSideJob(30000, at, func() (bool, error) { return false, nil })
if slotHintPending.Load() != 0 {
t.Fatal("vérification armée sans effacement")
}
}
// Pendant un travail annexe, ses propres prompts ne comptent pas : ni retenus
// comme « la conversation », ni comparés à elle.
func TestNoteEnginePromptIgnoreLesTravauxAnnexes(t *testing.T) {
slotHintShown.Store(false)
lastEnginePrompt.Store(30000)
slotHintPending.Store(30000)
sideJobs.Add(1) // ce que fait engineSideJob en entrant
if h := noteEnginePrompt(40000, 10, true); h != "" {
t.Fatalf("prompt d'un travail annexe comparé : %q", h)
}
if lastEnginePrompt.Load() != 30000 || slotHintPending.Load() != 30000 {
t.Fatalf("prompt annexe retenu : dernier %d, en attente %d", lastEnginePrompt.Load(), slotHintPending.Load())
}
sideJobs.Add(-1)
slotHintPending.Store(0)
slotHintShown.Store(false)
}
+55 -13
View File
@@ -307,7 +307,7 @@ func mcpTools() []Tool {
}
// Préfixe le serveur d'origine dans la description : aide le modèle à
// choisir entre outils similaires de serveurs différents.
desc = "[MCP: " + name + "] " + desc
desc = mcpTagDescription(name, desc)
out = append(out, Tool{
Type: "function",
Function: ToolFunction{
@@ -449,22 +449,64 @@ func flattenMCPContent(res *mcpsdk.CallToolResult) string {
return text
}
// mcpPromptLine renvoie une ligne système listant les serveurs MCP connectés et
// leur nombre d'outils, pour situer le modèle. Ne force PAS de connexion : lit
// seulement l'état déjà établi par mcpTools() (appelé par EnabledTools sur le
// même tour), pour ne pas payer deux fois le handshake.
func mcpPromptLine() string {
mcpMgr.mu.Lock()
defer mcpMgr.mu.Unlock()
var names []string
for name, s := range mcpMgr.sessions {
if s.sess != nil && len(s.tools) > 0 {
names = append(names, fmt.Sprintf("%s (%d)", name, len(s.tools)))
// mcpTagDescription préfixe la description d'un outil MCP par le nom d'origine
// de son serveur, que le nom d'outil (assaini) ne garde pas. mcpToolServer le
// relit pour la ligne du préambule : ce format n'existe qu'ici, pour que les
// deux ne puissent pas diverger en silence.
func mcpTagDescription(server, desc string) string {
return "[MCP: " + server + "] " + desc
}
// mcpToolServer retrouve le serveur d'origine d'un outil MCP annoncé, d'après
// le préfixe posé par mcpTagDescription. Le nom brut d'un serveur peut lui-même
// contenir « ] » : on retient la première coupure dont la forme assainie est
// bien celle qui figure dans le nom de l'outil.
func mcpToolServer(t Tool) (string, bool) {
name := t.Function.Name
if !strings.HasPrefix(name, "mcp__") {
return "", false
}
rest, ok := strings.CutPrefix(t.Function.Description, "[MCP: ")
if !ok {
return "", false
}
for i := 0; ; {
j := strings.Index(rest[i:], "] ")
if j < 0 {
return "", false
}
if server := rest[:i+j]; strings.HasPrefix(name, "mcp__"+mcpSanitize(server)+"__") {
return server, true
}
i += j + 1
}
}
// mcpPromptLine renvoie une ligne système listant les serveurs MCP annoncés et
// leur nombre d'outils, pour situer le modèle. Elle se DÉDUIT des outils
// réellement envoyés ce tour-ci (EnabledTools), pas de l'état du pool.
//
// Lue dans le pool, elle arrivait en retard d'un tour : le préambule est bâti
// avant que mcpTools n'ouvre les connexions, si bien que le premier tour après
// un démarrage partait sans la ligne et le second avec — tout le prompt à
// recalculer pour une ligne. Elle comptait aussi les outils masqués par
// l'utilisateur, et s'affichait pour un rôle (planner…) qui ne reçoit aucun
// outil MCP. Tirée de la même tranche que les schémas, elle ne peut plus les
// contredire.
func mcpPromptLine(tools []Tool) string {
counts := map[string]int{}
for _, t := range tools {
if server, ok := mcpToolServer(t); ok {
counts[server]++
}
}
if len(names) == 0 {
if len(counts) == 0 {
return ""
}
names := make([]string, 0, len(counts))
for name, n := range counts {
names = append(names, fmt.Sprintf("%s (%d)", name, n))
}
sort.Strings(names)
return "\n\nMCP servers connected (their tools are prefixed mcp__<server>__<tool>): " + strings.Join(names, ", ") + "."
}
+89
View File
@@ -0,0 +1,89 @@
package loki
import (
"strings"
"testing"
)
func mcpTestTool(server, tool string) Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: mcpExposedName(server, tool),
Description: mcpTagDescription(server, "décrit "+tool),
}}
}
// La ligne MCP du préambule se déduit des outils envoyés sur le MÊME tour : elle
// ne peut ni manquer au premier tour après un démarrage (pool encore vide quand
// le préambule était bâti), ni compter un outil masqué, ni apparaître pour un
// rôle qui ne reçoit aucun outil MCP.
func TestMCPPromptLineDeriveDesOutils(t *testing.T) {
cases := []struct {
name string
tools []Tool
want string
}{
{"aucun outil", nil, ""},
{"aucun outil MCP", []Tool{bashTool(), readTool()}, ""},
{"un serveur", []Tool{bashTool(), mcpTestTool("github", "issues"), mcpTestTool("github", "prs")},
"github (2)"},
{"plusieurs serveurs, triés", []Tool{mcpTestTool("zeta", "a"), mcpTestTool("alpha.io", "b"), mcpTestTool("zeta", "c")},
"alpha.io (1), zeta (2)"},
{"description sans le préfixe [MCP: …] ignorée", []Tool{{Type: "function", Function: ToolFunction{Name: "mcp__x__y", Description: "rien"}}},
""},
{"description vide après le préfixe", []Tool{{Type: "function", Function: ToolFunction{Name: "mcp__x__y", Description: "[MCP: x] "}}},
"x (1)"},
{"nom de serveur contenant « ] »", []Tool{mcpTestTool("a] b", "t"), mcpTestTool("a] b", "u")},
"a] b (2)"},
{"préfixe d'un autre serveur que celui du nom d'outil", []Tool{{Type: "function", Function: ToolFunction{Name: "mcp__x__y", Description: "[MCP: z] y"}}},
""},
}
for _, c := range cases {
got := mcpPromptLine(c.tools)
if c.want == "" {
if got != "" {
t.Errorf("%s : ligne %q, attendu aucune", c.name, got)
}
continue
}
if !strings.HasSuffix(got, ": "+c.want+".") {
t.Errorf("%s : ligne %q, attendu la liste %q", c.name, got, c.want)
}
}
}
// Le préambule et les outils du tour viennent de la même tranche : le rôle
// planner (aucun outil MCP) n'a pas de ligne MCP, le fil principal l'a dès le
// premier tour.
func TestBaseSystemPromptLigneMCPSuitLesOutils(t *testing.T) {
// $LOKI_HOME isolé : EnabledTools lit la configuration MCP, et celle de la
// machine de test (serveurs réels, connexions comprises) fausserait le cas
// « sans outil MCP ».
testHome(t)
caps := Caps{Agent: true, Mem: MemOff}
with := append(EnabledTools(caps), mcpTestTool("github", "issues"))
if sp := baseSystemPrompt(caps, with); !strings.Contains(sp, "MCP servers connected") || !strings.Contains(sp, "github (1)") {
t.Fatalf("ligne MCP absente alors qu'un outil MCP part sur ce tour :\n%s", sp)
}
if sp := baseSystemPrompt(caps, EnabledTools(caps)); strings.Contains(sp, "MCP servers") {
t.Fatalf("ligne MCP sans aucun outil MCP envoyé :\n%s", sp)
}
planner := Caps{Agent: true, Code: true, Role: "planner", Mem: MemOff}
if sp := baseSystemPrompt(planner, EnabledTools(planner)); strings.Contains(sp, "MCP servers") {
t.Fatalf("le planner ne reçoit aucun outil MCP, sa ligne ne doit pas l'annoncer :\n%s", sp)
}
}
// prepareTurn rend la séquence ET les outils qu'elle décrit : le préambule
// injecté est exactement celui de baseSystemPrompt sur ces outils.
func TestPrepareTurnPreambuleCoherentAvecOutils(t *testing.T) {
testHome(t)
caps := Caps{Agent: true, Mem: MemOff}
sent, tools := prepareTurn([]Message{{Role: "user", Content: "salut"}}, caps)
if len(sent) != 2 || sent[0].Role != "system" {
t.Fatalf("séquence inattendue : %#v", sent)
}
sys, _ := sent[0].Content.(string)
if !strings.HasPrefix(sys, baseSystemPrompt(caps, tools)) {
t.Fatalf("le préambule ne correspond pas aux outils renvoyés :\n%s", sys)
}
}
+4
View File
@@ -174,6 +174,9 @@ var encryptedBuckets = []string{bkChat, bkRecall, bkTracker, bkToolRes}
// reencryptChatStores (re)chiffre les buckets de conversation. Exige la DEK.
func reencryptChatStores() error {
// Écritures différées (chat_persist.go) d'abord : écrites pendant le
// passage, elles pourraient être écrasées par la relecture d'avant.
persistQ.flush()
healPlainStoreKeys()
for _, b := range encryptedBuckets {
if err := reencryptBucket(b); err != nil {
@@ -185,6 +188,7 @@ func reencryptChatStores() error {
// decryptChatStores remet en clair les buckets de conversation. Exige la DEK.
func decryptChatStores() error {
persistQ.flush() // même raison que reencryptChatStores
for _, b := range encryptedBuckets {
if err := decryptBucket(b); err != nil {
return err
+1 -1
View File
@@ -43,7 +43,7 @@ func TestInjectSkillsKeepsProjectMessageSeparate(t *testing.T) {
{Role: "system", Content: projectContextPrefix + " — projet A"},
{Role: "user", Content: "bonjour"},
}
out := normalizeSystemMessages(InjectSkills(in, Caps{Agent: true}))
out := normalizeSystemMessages(InjectSkills(in, Caps{Agent: true}, nil))
if s, _ := out[0].Content.(string); out[0].Role != "system" || strings.Contains(s, projectContextPrefix) {
t.Fatalf("le contexte projet ne doit pas rester dans le système : %#v", out[0])
}
+584
View File
@@ -0,0 +1,584 @@
package loki
import (
"bytes"
"context"
"encoding/json"
"fmt"
"hash/fnv"
"math"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Télémétrie par complétion : combien du prompt venait du cache, combien a été
// recalculé, à quelle vitesse, et ce que le décodage spéculatif a gardé.
//
// Rien de tout ça ne retourne au moteur : aucun champ ajouté aux requêtes, le
// comptage du contexte (usage.prompt_tokens → CtxUsed → compaction) reste
// celui d'avant. On LIT ce que llama.cpp envoie déjà sur son chunk final, on le
// garde en mémoire (anneau borné, aucun texte de message) et on l'expose en
// agrégats sur /api/perf/summary. Sans ces chiffres, impossible de savoir si une
// ligne volatile s'est glissée dans le prompt, si une mise à jour du moteur a
// cassé les points de reprise, ou si l'acceptation du MTP s'est effondrée.
// Nature d'une complétion. Elle voyage dans le contexte (withPerf) et jamais
// dans Caps : Caps est une liste de capacités, recopiée par valeur partout.
const (
perfMain = "main" // tour de la conversation (et ses étapes d'outils)
perfSubagent = "subagent" // sous-agent du mode Code
perfVerify = "verify" // passe de vérification du mode Code
perfTask = "task" // tâche planifiée
perfCompact = "compact" // résumé de compaction
perfBench = "bench" // mesure du moteur (cache_prompt:false)
perfForeign = "foreign" // client externe passé par /v1 : vu, pas mesuré
)
type perfCtxKey struct{}
type perfTag struct{ kind, conv string }
// withPerf étiquette les complétions lancées sous ctx.
func withPerf(ctx context.Context, kind, conv string) context.Context {
return context.WithValue(ctx, perfCtxKey{}, perfTag{kind: kind, conv: conv})
}
// withPerfKind change la nature en gardant la conversation : un sous-agent ou
// une vérification reste rattaché à la discussion qui l'a lancé.
func withPerfKind(ctx context.Context, kind string) context.Context {
t := perfTagOf(ctx)
t.kind = kind
return context.WithValue(ctx, perfCtxKey{}, t)
}
func perfTagOf(ctx context.Context) perfTag {
t, _ := ctx.Value(perfCtxKey{}).(perfTag)
if t.kind == "" {
t.kind = perfMain
}
return t
}
// perfNum : un nombre de télémétrie, décodé sans JAMAIS échouer. Un fork ou une
// API tierce qui l'envoie en flottant, en chaîne ou pas du tout le laisse
// simplement « inconnu » : une valeur d'affichage ne doit pas pouvoir jeter le
// chunk final (texte, finish_reason, usage) ni faire échouer une compaction.
type perfNum struct {
f float64
ok bool
}
func (n *perfNum) UnmarshalJSON(b []byte) error {
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
if f, err := strconv.ParseFloat(s, 64); err == nil && !math.IsNaN(f) && !math.IsInf(f, 0) && f >= 0 && f < 1e12 {
*n = perfNum{f: f, ok: true}
}
return nil
}
func (n perfNum) int() int { return int(n.f) }
func (n perfNum) ptr() *int {
if !n.ok {
return nil
}
v := int(n.f)
return &v
}
// perfWire : les champs de télémétrie du chunk final, lus dans un DEUXIÈME
// décodage, séparé de streamChunk et dont l'erreur est ignorée. Les compteurs
// cache_n/draft_n n'existent pas sur tous les moteurs (draft_n n'apparaît
// qu'avec un brouillon actif) : absents, ils restent inconnus, jamais 0.
type perfWire struct {
Timings *struct {
PromptN perfNum `json:"prompt_n"`
PromptMs perfNum `json:"prompt_ms"`
PromptPerSecond perfNum `json:"prompt_per_second"`
PredictedN perfNum `json:"predicted_n"`
PredictedMs perfNum `json:"predicted_ms"`
PredictedPerSec perfNum `json:"predicted_per_second"`
CacheN perfNum `json:"cache_n"`
DraftN perfNum `json:"draft_n"`
DraftNAccepted perfNum `json:"draft_n_accepted"`
} `json:"timings"`
Usage *struct {
PromptTokens perfNum `json:"prompt_tokens"`
CompletionTokens perfNum `json:"completion_tokens"`
PromptTokensDetails *struct {
CachedTokens perfNum `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
} `json:"usage"`
}
func decodePerfWire(data []byte) perfWire {
// Decoder plutôt qu'Unmarshal : un octet parasite après la réponse ne doit
// pas tout rendre inconnu. Ce qui a pu être lu l'est ; le reste reste inconnu.
var w perfWire
_ = json.NewDecoder(bytes.NewReader(data)).Decode(&w)
return w
}
// cachedTokens : usage.prompt_tokens_details.cached_tokens (format OpenAI).
func (w perfWire) cachedTokens() perfNum {
if w.Usage == nil || w.Usage.PromptTokensDetails == nil {
return perfNum{}
}
return w.Usage.PromptTokensDetails.CachedTokens
}
// applyPerf reporte la télémétrie d'un chunk sur les stats. timings.cache_n a
// la priorité ; cached_tokens ne fait que combler un cache encore inconnu (les
// deux viennent du même compteur côté llama.cpp, mais seul le second existe
// sur une API tierce).
func (s *StatsEvent) applyPerf(w perfWire) {
if t := w.Timings; t != nil {
if p := t.CacheN.ptr(); p != nil {
s.CacheTokens = p
}
if p := t.DraftN.ptr(); p != nil {
s.DraftN = p
}
if p := t.DraftNAccepted.ptr(); p != nil {
s.DraftAccepted = p
}
}
if s.CacheTokens == nil {
s.CacheTokens = w.cachedTokens().ptr()
}
}
// perfRec : une complétion. Des identifiants et des compteurs, jamais de texte.
type perfRec struct {
At time.Time `json:"at"`
Seq uint64 `json:"seq"`
Kind string `json:"kind"`
Conv string `json:"conv,omitempty"`
Iter int `json:"iter"`
Complete bool `json:"complete"`
// Total : usage.prompt_tokens, la taille du prompt entier. New : prompt_n,
// les jetons réellement recalculés. Cached : cache_n, absent si inconnu.
Total int `json:"total,omitempty"`
Cached *int `json:"cached,omitempty"`
New int `json:"new,omitempty"`
PPms float64 `json:"pp_ms,omitempty"`
PPtps float64 `json:"pp_tps,omitempty"`
Gen int `json:"gen,omitempty"`
TGtps float64 `json:"tg_tps,omitempty"`
DraftN *int `json:"draft_n,omitempty"`
DraftAcc *int `json:"draft_accepted,omitempty"`
TTFTms *int64 `json:"ttft_ms,omitempty"`
Lost *int `json:"lost,omitempty"`
LostAfter string `json:"lost_after,omitempty"`
}
// perfRecFromStats : la complétion telle que runChat l'a vue.
func perfRecFromStats(t perfTag, iter int, complete bool, s StatsEvent) perfRec {
return perfRec{
Kind: t.kind, Conv: t.conv, Iter: iter, Complete: complete,
Total: s.PromptTokensTotal, Cached: s.CacheTokens, New: s.PromptTokens,
PPms: s.PromptMs, PPtps: s.PromptPerSecond, Gen: s.GenTokens, TGtps: s.GenPerSecond,
DraftN: s.DraftN, DraftAcc: s.DraftAccepted, TTFTms: s.TTFTms,
}
}
// perfRecFromWire : une réponse NON streamée (compaction), lue d'un bloc.
func perfRecFromWire(t perfTag, w perfWire) perfRec {
r := perfRec{Kind: t.kind, Conv: t.conv}
if tm := w.Timings; tm != nil {
r.New, r.PPms, r.PPtps = tm.PromptN.int(), tm.PromptMs.f, tm.PromptPerSecond.f
r.Gen, r.TGtps = tm.PredictedN.int(), tm.PredictedPerSec.f
r.Cached, r.DraftN, r.DraftAcc = tm.CacheN.ptr(), tm.DraftN.ptr(), tm.DraftNAccepted.ptr()
}
if u := w.Usage; u != nil {
r.Total = u.PromptTokens.int()
if r.Gen == 0 {
r.Gen = u.CompletionTokens.int()
}
}
if r.Cached == nil {
r.Cached = w.cachedTokens().ptr()
}
r.Complete = r.Total > 0 || w.Timings != nil
return r
}
// perfPrefix : empreintes cumulées des messages envoyés (l'empreinte i couvre
// les messages 0..i). C'est ce qui dit qu'une requête PROLONGE la précédente
// de la même conversation — l'identifiant seul ne suffit pas : une compaction,
// une édition ou une autre tâche dans la même discussion repartent d'ailleurs.
func perfPrefix(msgs []Message) []uint64 {
h := fnv.New64a()
out := make([]uint64, len(msgs))
for i, m := range msgs {
b, _ := json.Marshal(m)
h.Write(b)
h.Write([]byte{0})
out[i] = h.Sum64()
}
return out
}
const (
perfRingMax = 5000 // ~1 Mio en mémoire au pire
perfConvMax = 128 // conversations suivies pour le calcul de la perte
perfBetween = 500 // entrées remontées au plus pour nommer ce qui s'est intercalé
)
// perfConvState : la dernière complétion terminée d'une conversation (et d'une
// nature : le fil d'un sous-agent n'est pas celui de son builder).
type perfConvState struct {
seq uint64
n int
hash uint64
total int
}
type perfStore struct {
mu sync.Mutex
seq uint64
ring []perfRec
head int // prochaine case à écraser quand l'anneau est plein
convs map[string]perfConvState
}
var perfLog = &perfStore{}
// record range la complétion et calcule sa perte de cache :
//
// lost = max(0, total précédent − cache_n)
//
// seulement quand le cache est connu, que la complétion est allée au bout, et
// que ses messages PROLONGENT strictement ceux de la précédente complétion de
// la même conversation et de la même nature. Ailleurs (nouvelle discussion,
// compaction, flux coupé) la perte reste inconnue plutôt que fausse.
func (p *perfStore) record(r perfRec, prefix []uint64) perfRec {
p.mu.Lock()
defer p.mu.Unlock()
p.seq++
r.Seq = p.seq
if r.At.IsZero() {
r.At = time.Now()
}
key := r.Kind + "\x00" + r.Conv
if p.convs == nil {
p.convs = map[string]perfConvState{}
}
if r.Complete && len(prefix) > 0 {
prev, had := p.convs[key]
if had && r.Cached != nil && prev.total > 0 && prev.n > 0 && len(prefix) > prev.n && prefix[prev.n-1] == prev.hash {
lost := max(0, prev.total-*r.Cached)
r.Lost = &lost
if lost > 0 {
r.LostAfter = p.between(prev.seq, key)
}
}
if r.Total > 0 {
p.convs[key] = perfConvState{seq: r.Seq, n: len(prefix), hash: prefix[len(prefix)-1], total: r.Total}
p.evictConvs()
}
}
if len(p.ring) < perfRingMax {
p.ring = append(p.ring, r)
} else {
p.ring[p.head] = r
p.head = (p.head + 1) % perfRingMax
}
return r
}
// between nomme les natures des complétions passées par le moteur depuis seq
// (un sous-agent, un client externe…) : la cause probable d'une perte. Vide =
// rien vu d'autre, perte non attribuée.
func (p *perfStore) between(seq uint64, key string) string {
seen := map[string]bool{}
n := len(p.ring)
// De la plus récente à la plus ancienne. Tant que l'anneau n'est pas plein,
// head vaut 0 et la plus récente est la dernière case : même formule.
for i := 0; i < n && i < perfBetween; i++ {
e := p.ring[((p.head-1-i)%n+n)%n]
if e.Seq <= seq {
break
}
if e.Kind+"\x00"+e.Conv != key {
seen[e.Kind] = true
}
}
kinds := make([]string, 0, len(seen))
for k := range seen {
kinds = append(kinds, k)
}
sort.Strings(kinds)
return strings.Join(kinds, ",")
}
// evictConvs borne l'état par conversation : on oublie la plus ancienne.
func (p *perfStore) evictConvs() {
for len(p.convs) > perfConvMax {
oldK, oldS := "", uint64(math.MaxUint64)
for k, s := range p.convs {
if s.seq < oldS {
oldK, oldS = k, s.seq
}
}
delete(p.convs, oldK)
}
}
// snapshot : copie de l'anneau, de la plus ancienne à la plus récente.
func (p *perfStore) snapshot() []perfRec {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]perfRec, 0, len(p.ring))
out = append(out, p.ring[p.head:]...)
return append(out, p.ring[:p.head]...)
}
// perfRecord range une complétion et, si LOKI_PERF_LOG est posée, l'écrit sur
// stderr. Une ligne par complétion, c'est des dizaines par tour d'agent : pas
// par défaut, les journaux Docker ont déjà celles de llama-server.
func perfRecord(r perfRec, prefix []uint64) perfRec {
r = perfLog.record(r, prefix)
if perfLogOn() {
fmt.Fprintln(os.Stderr, perfLine(r))
}
return r
}
// perfNoteForeign : une requête d'un client externe vient de passer par /v1.
// Ses chiffres ne sont pas lus (le flux est relayé tel quel), mais sa trace
// suffit à expliquer la perte de cache du tour suivant.
func perfNoteForeign() {
perfRecord(perfRec{Kind: perfForeign}, nil)
}
func perfLogOn() bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv("LOKI_PERF_LOG")))
return v != "" && v != "0" && v != "false" && v != "off" && v != "no"
}
func perfLine(r perfRec) string {
opt := func(p *int) string {
if p == nil {
return "?"
}
return strconv.Itoa(*p)
}
draft := "?"
if r.DraftN != nil {
draft = opt(r.DraftAcc) + "/" + strconv.Itoa(*r.DraftN)
}
ttft := "?"
if r.TTFTms != nil {
ttft = strconv.FormatInt(*r.TTFTms, 10)
}
lost := opt(r.Lost)
if r.LostAfter != "" {
lost += "(" + r.LostAfter + ")"
}
conv := r.Conv
if conv == "" {
conv = "-"
}
return fmt.Sprintf("[perf] kind=%s conv=%s iter=%d total=%d cached=%s new=%d pp_ms=%.0f pp_tps=%.1f gen=%d tg_tps=%.1f draft=%s ttft_ms=%s lost=%s complete=%t",
r.Kind, conv, r.Iter, r.Total, opt(r.Cached), r.New, r.PPms, r.PPtps, r.Gen, r.TGtps, draft, ttft, lost, r.Complete)
}
// perfLostAlertAt : la perte au-delà de laquelle l'interface la signale. Sur un
// modèle hybride (couches récurrentes), le cache ne reprend qu'à un point de
// reprise : perdre jusqu'à un espacement de points (--checkpoint-min-step,
// 2048 posé par Loki) plus un micro-batch est l'état NORMAL, pas un incident.
// Le signaler à chaque étape noierait les vraies pertes.
//
// Même ordre de priorité que le lancement (ckptArgs) : un -cms d'EXTRA_ARGS ou
// LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT l'emporte sur CKPT_MIN_STEP, un -ub
// d'EXTRA_ARGS sur UBATCH.
func perfLostAlertAt(cfg map[string]string) int {
const base = 1000
extra := splitArgs(cfg["EXTRA_ARGS"])
num := func(vals ...string) int {
for _, v := range vals {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
return n
}
}
return 0
}
step := num(flagValue(extra, "-cms", "--checkpoint-min-step"),
os.Getenv("LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"), cfg["CKPT_MIN_STEP"])
if m := strings.TrimSpace(cfg["MODEL"]); step == 0 && m != "" {
if p, err := resolveServeModelPath(m); err == nil {
if g, err := ggufMeta(p); err == nil && ggufHybrid(&g) {
step = 2048
}
}
}
if step == 0 {
return base
}
ub := num(flagValue(extra, "-ub", "--ubatch-size"), cfg["UBATCH"])
if ub == 0 {
ub = 512
}
return max(base, step+ub)
}
// perfAlert : la complétion mérite d'être signalée — une perte au-delà du seuil,
// ou un gros prompt repris de zéro alors que le cache est connu.
func perfAlert(r perfRec, threshold int) bool {
if r.Lost != nil && *r.Lost > threshold {
return true
}
return r.Cached != nil && *r.Cached == 0 && r.Total >= 8*threshold
}
// --- /api/perf/summary -------------------------------------------------------
type perfKindSum struct {
Completions int `json:"completions"`
Incomplete int `json:"incomplete"`
// Turns : premières complétions (iter 0) — un tour utilisateur, un
// sous-agent, une passe de vérification…
Turns int `json:"turns"`
NewTokens int `json:"new_tokens"`
PrefillSec float64 `json:"prefill_sec"`
NewTokensPerTurn float64 `json:"new_tokens_per_turn,omitempty"`
PrefillSecPerTurn float64 `json:"prefill_sec_per_turn,omitempty"`
LostEvents int `json:"lost_events"`
LostTokens int `json:"lost_tokens"`
TTFTMedianMs *float64 `json:"ttft_median_ms,omitempty"`
}
type perfDepthSum struct {
Bucket string `json:"bucket"`
N int `json:"n"`
PPMedianTPS *float64 `json:"pp_median_tps,omitempty"`
TGMedianTPS *float64 `json:"tg_median_tps,omitempty"`
}
type perfSummary struct {
Entries int `json:"entries"`
Since time.Time `json:"since,omitzero"`
// CacheHitRatio : Σ cached / Σ total sur les complétions où les deux sont
// connus. Absent = moteur qui ne dit rien de son cache.
CacheHitRatio *float64 `json:"cache_hit_ratio,omitempty"`
Kinds map[string]*perfKindSum `json:"kinds"`
Depth []perfDepthSum `json:"depth"`
DraftN int `json:"draft_n"`
DraftAccepted int `json:"draft_accepted"`
DraftRate *float64 `json:"draft_rate,omitempty"`
}
var perfDepthBuckets = []struct {
name string
max int
}{{"0-8k", 8 << 10}, {"8-16k", 16 << 10}, {"16-32k", 32 << 10}, {"32k+", math.MaxInt}}
func perfMedian(v []float64) *float64 {
if len(v) == 0 {
return nil
}
sort.Float64s(v)
m := v[len(v)/2]
if len(v)%2 == 0 {
m = (v[len(v)/2-1] + v[len(v)/2]) / 2
}
return &m
}
func perfSummarize(recs []perfRec) perfSummary {
s := perfSummary{Entries: len(recs), Kinds: map[string]*perfKindSum{}}
if len(recs) > 0 {
s.Since = recs[0].At
}
var sumCached, sumTotal int
pp := make([][]float64, len(perfDepthBuckets))
tg := make([][]float64, len(perfDepthBuckets))
ttft := map[string][]float64{}
for _, r := range recs {
k := s.Kinds[r.Kind]
if k == nil {
k = &perfKindSum{}
s.Kinds[r.Kind] = k
}
if !r.Complete {
k.Incomplete++
continue
}
k.Completions++
if r.Iter == 0 {
k.Turns++
}
k.NewTokens += r.New
k.PrefillSec += r.PPms / 1000
if r.Lost != nil && *r.Lost > 0 {
k.LostEvents++
k.LostTokens += *r.Lost
}
if r.TTFTms != nil {
ttft[r.Kind] = append(ttft[r.Kind], float64(*r.TTFTms))
}
if r.Cached != nil && r.Total > 0 {
sumCached += min(*r.Cached, r.Total)
sumTotal += r.Total
}
depth := r.Total
if depth == 0 {
depth = r.New
}
for i, b := range perfDepthBuckets {
if depth < b.max {
// Débit de lecture : seulement sur un vrai prefill (quelques jetons
// nouveaux donnent des débits sans signification).
if r.PPtps > 0 && r.New >= 64 {
pp[i] = append(pp[i], r.PPtps)
}
if r.TGtps > 0 && r.Gen > 1 {
tg[i] = append(tg[i], r.TGtps)
}
break
}
}
if r.DraftN != nil {
s.DraftN += *r.DraftN
if r.DraftAcc != nil {
s.DraftAccepted += *r.DraftAcc
}
}
}
for kind, k := range s.Kinds {
if k.Turns > 0 {
k.NewTokensPerTurn = float64(k.NewTokens) / float64(k.Turns)
k.PrefillSecPerTurn = k.PrefillSec / float64(k.Turns)
}
k.TTFTMedianMs = perfMedian(ttft[kind])
}
if sumTotal > 0 {
r := float64(sumCached) / float64(sumTotal)
s.CacheHitRatio = &r
}
if s.DraftN > 0 {
r := float64(s.DraftAccepted) / float64(s.DraftN)
s.DraftRate = &r
}
for i, b := range perfDepthBuckets {
s.Depth = append(s.Depth, perfDepthSum{Bucket: b.name, N: max(len(pp[i]), len(tg[i])),
PPMedianTPS: perfMedian(pp[i]), TGMedianTPS: perfMedian(tg[i])})
}
return s
}
// handlePerfSummary : GET /api/perf/summary (derrière la clé de pilotage).
// Des agrégats seulement : aucun texte de message n'est jamais retenu.
func handlePerfSummary(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET seulement"})
return
}
sendJSON(w, http.StatusOK, perfSummarize(perfLog.snapshot()))
}
+339
View File
@@ -0,0 +1,339 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// freshPerf isole l'anneau de télémétrie d'un test.
func freshPerf(t *testing.T) {
t.Helper()
old := perfLog
perfLog = &perfStore{}
t.Cleanup(func() { perfLog = old })
}
func iptr(v int) *int { return &v }
// Chunk final d'un llama-server récent, en flux, avec MTP actif : choices
// vide, timings et usage à la racine.
const llamaFinalChunk = `{"choices":[],"created":1,"id":"x","model":"m","object":"chat.completion.chunk",` +
`"usage":{"completion_tokens":120,"prompt_tokens":41484,"total_tokens":41604,"prompt_tokens_details":{"cached_tokens":41230}},` +
`"timings":{"cache_n":41230,"prompt_n":254,"prompt_ms":310.2,"prompt_per_token":1.2,"prompt_per_second":818.8,` +
`"predicted_n":120,"predicted_ms":2400,"predicted_per_token":20,"predicted_per_second":50,"draft_n":90,"draft_n_accepted":71}}`
func TestPerfWireDecode(t *testing.T) {
cases := []struct {
name string
data string
cache, draft, accept *int
}{
{"chunk final llama.cpp", llamaFinalChunk, iptr(41230), iptr(90), iptr(71)},
// Sans brouillon, llama.cpp n'émet pas draft_n : inconnu, pas 0.
{"sans brouillon", `{"timings":{"cache_n":12,"prompt_n":3}}`, iptr(12), nil, nil},
// Tout recalculé : un 0 CONNU, qu'il faut pouvoir montrer.
{"cache vide", `{"timings":{"cache_n":0,"prompt_n":3000}}`, iptr(0), nil, nil},
// API tierce : seulement le format OpenAI.
{"usage seul", `{"usage":{"prompt_tokens":900,"prompt_tokens_details":{"cached_tokens":512}}}`, iptr(512), nil, nil},
{"moteur muet", `{"usage":{"prompt_tokens":900}}`, nil, nil, nil},
// Types de travers (fork, passerelle) : lu si c'est un nombre, sinon inconnu.
{"types de travers", `{"timings":{"cache_n":"abc","draft_n":"12","draft_n_accepted":7.0}}`, nil, iptr(12), iptr(7)},
// Octets parasites après la réponse (proxy) : la première valeur compte.
{"octets parasites", `{"timings":{"cache_n":12}}` + "\nxyz", iptr(12), nil, nil},
{"structure de travers", `{"timings":"nope","usage":{"prompt_tokens_details":{"cached_tokens":5.9}}}`, iptr(5), nil, nil},
}
eq := func(a, b *int) bool { return (a == nil) == (b == nil) && (a == nil || *a == *b) }
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var s StatsEvent
s.applyPerf(decodePerfWire([]byte(c.data)))
if !eq(s.CacheTokens, c.cache) || !eq(s.DraftN, c.draft) || !eq(s.DraftAccepted, c.accept) {
t.Fatalf("cache=%v draft=%v accepted=%v", s.CacheTokens, s.DraftN, s.DraftAccepted)
}
})
}
}
// Le chunk final peut aussi porter le dernier texte et le finish_reason (forks,
// API compatibles). Un compteur de télémétrie mal typé ne doit pas le faire
// sauter : avant, cached_tokens strictement entier jetait tout le chunk.
func TestStreamChunkIgnoresOddTelemetry(t *testing.T) {
data := `{"choices":[{"delta":{"content":"fin"},"finish_reason":"stop"}],` +
`"usage":{"prompt_tokens":500,"completion_tokens":3,"prompt_tokens_details":{"cached_tokens":480.5}},` +
`"timings":{"prompt_n":20,"predicted_n":3,"draft_n":"x","cache_n":480.0}}`
var c streamChunk
if err := json.Unmarshal([]byte(data), &c); err != nil {
t.Fatalf("chunk jeté : %v", err)
}
if len(c.Choices) != 1 || c.Choices[0].Delta.Content != "fin" || c.Choices[0].FinishReason != "stop" || c.Usage.PromptTokens != 500 {
t.Fatalf("chunk mal lu : %+v", c)
}
var s StatsEvent
s.applyPerf(decodePerfWire([]byte(data)))
if s.CacheTokens == nil || *s.CacheTokens != 480 || s.DraftN != nil {
t.Fatalf("télémétrie : cache=%v draft=%v", s.CacheTokens, s.DraftN)
}
}
// De bout en bout : le dernier événement Stats porte la télémétrie, le texte et
// le comptage du contexte sont intacts, et rien n'est ajouté à la requête.
func TestRunChatPerfFinalStats(t *testing.T) {
testHome(t)
freshPerf(t)
var payload map[string]any
body := sseChunk("bonjour") +
`data: {"choices":[{"delta":{"content":" toi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":500,"completion_tokens":2,"prompt_tokens_details":{"cached_tokens":480.5}},"timings":{"prompt_n":20,"prompt_ms":10,"prompt_per_second":2000,"predicted_n":2,"predicted_ms":40,"predicted_per_second":50,"draft_n":"x"}}` + "\n\n" +
"data: [DONE]\n\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&payload)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
var content strings.Builder
var last *StatsEvent
ctx := withPerf(context.Background(), perfMain, "c1")
if _, err := runChat(ctx, []Message{{Role: "user", Content: "salut"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
if ev.Content != "" {
content.WriteString(ev.Content)
}
if ev.Stats != nil {
last = ev.Stats
}
return true
}); err != nil {
t.Fatal(err)
}
if content.String() != "bonjour toi" {
t.Fatalf("texte : %q", content.String())
}
if last == nil || last.PromptTokensTotal != 500 || last.Kind != perfMain || last.CacheTokens == nil || *last.CacheTokens != 480 || last.TTFTms == nil {
t.Fatalf("dernier Stats : %+v", last)
}
for _, k := range []string{"timings_per_token", "return_progress", "n_probs", "id_slot", "cache_prompt"} {
if _, ok := payload[k]; ok {
t.Fatalf("la télémétrie a ajouté %q à la requête", k)
}
}
recs := perfLog.snapshot()
if len(recs) != 1 || !recs[0].Complete || recs[0].Conv != "c1" || recs[0].Total != 500 || recs[0].New != 20 {
t.Fatalf("enregistrement : %+v", recs)
}
}
// Un flux coupé n'a ni cache ni total fiables : rangé incomplet, sans perte.
func TestRunChatPerfCutStreamIncomplete(t *testing.T) {
testHome(t)
freshPerf(t)
port := sseCuttingServer(t, sseChunk("début"), true)
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
_, _ = runChat(context.Background(), []Message{{Role: "user", Content: "x"}}, 0.7, Caps{}, func(StreamEvent) bool { return true })
recs := perfLog.snapshot()
if len(recs) != 1 || recs[0].Complete || recs[0].Lost != nil {
t.Fatalf("flux coupé : %+v", recs)
}
}
func TestPerfRecordLost(t *testing.T) {
a := Message{Role: "user", Content: "a"}
b := Message{Role: "assistant", Content: "b"}
c := Message{Role: "tool", Content: "c"}
z := Message{Role: "user", Content: "z"}
type step struct {
rec perfRec
msgs []Message
}
main := func(total int, cached *int) perfRec {
return perfRec{Kind: perfMain, Conv: "c1", Complete: true, Total: total, Cached: cached}
}
cases := []struct {
name string
steps []step
lost *int
lostAfter string
}{
{"prolonge, cache intact", []step{{main(1000, iptr(0)), []Message{a}}, {main(1200, iptr(1000)), []Message{a, b, c}}}, iptr(0), ""},
{"prolonge, cache perdu", []step{{main(1000, iptr(0)), []Message{a}}, {main(1200, iptr(300)), []Message{a, b, c}}}, iptr(700), ""},
{"diverge", []step{{main(1000, iptr(0)), []Message{a, b}}, {main(1200, iptr(10)), []Message{z, b, c}}}, nil, ""},
{"même liste, pas une suite", []step{{main(1000, iptr(0)), []Message{a, b}}, {main(1000, iptr(10)), []Message{a, b}}}, nil, ""},
{"cache inconnu", []step{{main(1000, iptr(0)), []Message{a}}, {main(1200, nil), []Message{a, b}}}, nil, ""},
{"complétion coupée", []step{{main(1000, iptr(0)), []Message{a}}, {perfRec{Kind: perfMain, Conv: "c1", Cached: iptr(0)}, []Message{a, b}}}, nil, ""},
{"autre discussion", []step{{main(1000, iptr(0)), []Message{a}}, {perfRec{Kind: perfMain, Conv: "c2", Complete: true, Total: 1200, Cached: iptr(0)}, []Message{a, b}}}, nil, ""},
{"un sous-agent ne se compare pas au builder", []step{
{main(1000, iptr(0)), []Message{a}},
{perfRec{Kind: perfSubagent, Conv: "c1", Complete: true, Total: 1200, Cached: iptr(0)}, []Message{a, b}},
}, nil, ""},
{"perte après sous-agent et client externe", []step{
{main(1000, iptr(0)), []Message{a}},
{perfRec{Kind: perfSubagent, Conv: "c1", Complete: true, Total: 300, Cached: iptr(0)}, []Message{z}},
{perfRec{Kind: perfForeign}, nil},
{main(1300, iptr(200)), []Message{a, b, c}},
}, iptr(800), "foreign,subagent"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
freshPerf(t)
var last perfRec
for _, s := range tc.steps {
var prefix []uint64
if s.msgs != nil {
prefix = perfPrefix(s.msgs)
}
last = perfLog.record(s.rec, prefix)
}
if (last.Lost == nil) != (tc.lost == nil) || (last.Lost != nil && *last.Lost != *tc.lost) {
t.Fatalf("lost = %v, attendu %v", last.Lost, tc.lost)
}
if last.LostAfter != tc.lostAfter {
t.Fatalf("lost_after = %q, attendu %q", last.LostAfter, tc.lostAfter)
}
})
}
}
func TestPerfRingBounded(t *testing.T) {
freshPerf(t)
for i := 0; i < perfRingMax+10; i++ {
perfLog.record(perfRec{Kind: perfMain, Conv: string(rune('a' + i%200)), Complete: true, Total: 10}, []uint64{uint64(i)})
}
recs := perfLog.snapshot()
if len(recs) != perfRingMax {
t.Fatalf("anneau : %d entrées", len(recs))
}
for i := 1; i < len(recs); i++ {
if recs[i].Seq != recs[i-1].Seq+1 {
t.Fatalf("ordre rompu en %d : %d puis %d", i, recs[i-1].Seq, recs[i].Seq)
}
}
if recs[len(recs)-1].Seq != perfRingMax+10 {
t.Fatalf("dernière entrée : %d", recs[len(recs)-1].Seq)
}
if len(perfLog.convs) > perfConvMax {
t.Fatalf("état par conversation non borné : %d", len(perfLog.convs))
}
}
func TestPerfSummarize(t *testing.T) {
recs := []perfRec{
{Kind: perfMain, Iter: 0, Complete: true, Total: 4000, Cached: iptr(3000), New: 1000, PPms: 2000, PPtps: 500, Gen: 50, TGtps: 40, DraftN: iptr(40), DraftAcc: iptr(30), TTFTms: ptr64(2100)},
{Kind: perfMain, Iter: 1, Complete: true, Total: 20000, Cached: iptr(19000), New: 1000, PPms: 1000, PPtps: 1000, Gen: 10, TGtps: 30, Lost: iptr(500), TTFTms: ptr64(1100)},
{Kind: perfSubagent, Iter: 0, Complete: true, Total: 2000, New: 2000, PPms: 4000, PPtps: 500},
{Kind: perfMain, Iter: 0},
{Kind: perfForeign},
}
s := perfSummarize(recs)
if s.Entries != 5 || s.CacheHitRatio == nil || *s.CacheHitRatio != 22000.0/24000.0 {
t.Fatalf("ratio : %+v", s.CacheHitRatio)
}
m := s.Kinds[perfMain]
if m.Completions != 2 || m.Incomplete != 1 || m.Turns != 1 || m.NewTokensPerTurn != 2000 || m.PrefillSecPerTurn != 3 || m.LostEvents != 1 || m.LostTokens != 500 {
t.Fatalf("main : %+v", m)
}
if m.TTFTMedianMs == nil || *m.TTFTMedianMs != 1600 {
t.Fatalf("ttft médian : %v", m.TTFTMedianMs)
}
if s.Kinds[perfForeign].Incomplete != 1 {
t.Fatalf("foreign : %+v", s.Kinds[perfForeign])
}
if s.DraftRate == nil || *s.DraftRate != 0.75 {
t.Fatalf("acceptation : %v", s.DraftRate)
}
if len(s.Depth) != 4 || s.Depth[0].PPMedianTPS == nil || *s.Depth[0].PPMedianTPS != 500 || s.Depth[2].TGMedianTPS == nil || *s.Depth[2].TGMedianTPS != 30 || s.Depth[3].N != 0 {
t.Fatalf("profondeurs : %+v", s.Depth)
}
// Aucune donnée : pas de ratio inventé.
if e := perfSummarize(nil); e.CacheHitRatio != nil || e.DraftRate != nil {
t.Fatalf("vide : %+v", e)
}
}
func ptr64(v int64) *int64 { return &v }
func TestPerfLostAlert(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
want int
}{
{"modèle classique", map[string]string{}, 1000},
{"espacement posé", map[string]string{"CKPT_MIN_STEP": "2048"}, 2560},
{"espacement et micro-batch", map[string]string{"CKPT_MIN_STEP": "4096", "UBATCH": "1024"}, 5120},
{"espacement illisible", map[string]string{"CKPT_MIN_STEP": "beaucoup"}, 1000},
// Comme au lancement : EXTRA_ARGS l'emporte sur les clés du preset.
{"-cms d'EXTRA_ARGS", map[string]string{"CKPT_MIN_STEP": "2048", "EXTRA_ARGS": "--checkpoint-min-step 8192 -ub 256"}, 8448},
{"-ub d'EXTRA_ARGS seul", map[string]string{"CKPT_MIN_STEP": "2048", "UBATCH": "1024", "EXTRA_ARGS": "-ub=2048"}, 4096},
}
t.Setenv("LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT", "")
for _, c := range cases {
if got := perfLostAlertAt(c.cfg); got != c.want {
t.Errorf("%s : seuil %d, attendu %d", c.name, got, c.want)
}
}
t.Setenv("LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT", "3000")
if got := perfLostAlertAt(map[string]string{"CKPT_MIN_STEP": "2048"}); got != 3512 {
t.Errorf("LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT ignorée : seuil %d", got)
}
if perfAlert(perfRec{Lost: iptr(900)}, 1000) || !perfAlert(perfRec{Lost: iptr(1500)}, 1000) {
t.Fatal("seuil de perte")
}
if perfAlert(perfRec{Lost: iptr(2000)}, 2560) {
t.Fatal("hybride : une perte sous l'espacement des points de reprise est normale")
}
if !perfAlert(perfRec{Cached: iptr(0), Total: 9000}, 1000) || perfAlert(perfRec{Cached: iptr(0), Total: 500}, 1000) || perfAlert(perfRec{Total: 9000}, 1000) {
t.Fatal("gros prompt sans cache")
}
}
// La compaction n'est pas streamée : sa télémétrie vient de la racine de la
// réponse, et un compteur mal typé ne la fait pas échouer.
func TestSummarizePerfLenient(t *testing.T) {
testHome(t)
freshPerf(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"- résumé"}}],` +
`"usage":{"prompt_tokens":3000,"completion_tokens":40,"prompt_tokens_details":{"cached_tokens":"beaucoup"}},` +
`"timings":{"cache_n":2900.0,"prompt_n":100,"prompt_ms":50,"prompt_per_second":2000,"predicted_n":40,"predicted_per_second":30}}`))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
got, err := summarizeTranscriptFor(withPerf(context.Background(), perfMain, "c9"), "transcript", false)
if err != nil || got != "- résumé" {
t.Fatalf("résumé : %q, %v", got, err)
}
recs := perfLog.snapshot()
if len(recs) != 1 || recs[0].Kind != perfCompact || recs[0].Conv != "c9" || !recs[0].Complete ||
recs[0].Cached == nil || *recs[0].Cached != 2900 || recs[0].Total != 3000 || recs[0].New != 100 {
t.Fatalf("enregistrement : %+v", recs)
}
}
func TestPerfLine(t *testing.T) {
l := perfLine(perfRec{Kind: perfMain, Conv: "c1", Total: 41484, Cached: iptr(41230), New: 254, DraftN: iptr(90), DraftAcc: iptr(71), Lost: iptr(0), Complete: true})
for _, want := range []string{"kind=main", "conv=c1", "cached=41230", "new=254", "draft=71/90", "ttft_ms=?", "lost=0"} {
if !strings.Contains(l, want) {
t.Fatalf("%q absent de %q", want, l)
}
}
t.Setenv("LOKI_PERF_LOG", "")
if perfLogOn() {
t.Fatal("ligne [perf] active par défaut")
}
t.Setenv("LOKI_PERF_LOG", "1")
if !perfLogOn() {
t.Fatal("LOKI_PERF_LOG=1 ignorée")
}
}
+5 -12
View File
@@ -164,24 +164,17 @@ func handleE2EChat(w http.ResponseWriter, r *http.Request) {
flusher, _ := w.(http.Flusher)
mu, stop := sseHeartbeat(w, flusher)
defer stop()
emit := func(obj map[string]any) bool {
// Un sceau par événement, même quand le direct les écrit par lots.
frame := func(obj map[string]any) ([]byte, bool) {
b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": obj}}})
nonce := make([]byte, 12)
if _, err := rand.Read(nonce); err != nil {
return false
return nil, false
}
sealedEv := append(nonce, gcm.Seal(nil, nonce, b, nil)...)
mu.Lock()
defer mu.Unlock()
if _, err := w.Write([]byte("data: " + base64.StdEncoding.EncodeToString(sealedEv) + "\n\n")); err != nil {
return false
}
if flusher != nil {
flusher.Flush()
}
return true
return []byte("data: " + base64.StdEncoding.EncodeToString(sealedEv) + "\n\n"), true
}
runChatStream(r.Context(), body, emit)
runChatStream(r.Context(), body, &sseStream{w: w, flusher: flusher, mu: mu, frame: frame})
}
// handleE2EReq : proxy de CONTRÔLE chiffré de bout en bout. Même enveloppe que le
+7
View File
@@ -519,6 +519,13 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
http.Error(w, "endpoint OpenAI désactivé via le relais (transiterait en clair) ; LOKI_LINK_ALLOW_OAI=1 pour l'autoriser", http.StatusForbidden)
return
}
// /slots en lecture seule : un pair distant n'efface, ne sauve ni ne
// restaure rien sur ce moteur (llm_slots.go).
if slotsWrite(r) {
http.Error(w, "/slots est en lecture seule via le relais", http.StatusMethodNotAllowed)
return
}
defer engineRequestStart()() // voir oaiHandler
lp.ServeHTTP(w, r)
return
}
+1 -1
View File
@@ -135,7 +135,7 @@ Moteur (loki-engine) :
enable | disable auto-démarrage au boot
edit éditer la configuration dans $EDITOR
switch [N] activer un preset de presets/ (interactif ou par numéro)
test | bench [N] vérifier que l'IA répond / mesurer prefill + decode tok/s
test | bench [N] [--full] vérifier que l'IA répond / mesurer prefill + decode (--full : en profondeur)
vram utilisation GPU/VRAM (nvidia-smi)
gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous)
set-api-key [clé] protéger llama-server (clé Bearer); vide = générer, "" = retirer
+7 -1
View File
@@ -62,8 +62,14 @@ func dbPath() string { return filepath.Join(LokiHome(), "loki.db") }
// withDB ouvre la base, exécute fn, puis referme — toujours, même en erreur.
func withDB(fn func(*bolt.DB) error) error {
path := dbPath()
return withDBAt(dbPath(), fn)
}
// withDBAt : withDB sur une base désignée. Sert à l'écrivain de la discussion
// (chat_persist.go), qui écrit APRÈS coup et doit viser la base de l'instant où
// l'instantané a été pris — pas celle que désignerait LOKI_HOME au moment de
// l'écriture (les tests en changent d'un test à l'autre).
func withDBAt(path string, fn func(*bolt.DB) error) error {
dbMu.Lock()
defer dbMu.Unlock()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+3
View File
@@ -11,6 +11,9 @@ func testHome(t *testing.T) string {
t.Setenv("LOKI_HOME", home)
firewallInert = true
t.Cleanup(func() { firewallInert = false })
// Écritures différées (chat_persist.go) : vidées AVANT que TempDir ne
// supprime la base — les cleanups passent dans l'ordre inverse.
t.Cleanup(persistQ.flush)
return home
}
+483
View File
@@ -0,0 +1,483 @@
package loki
import (
"context"
"encoding/json"
"math/rand"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"unicode/utf8"
)
// chunked découpe s en morceaux de 1 à max octets (graine fixe : reproductible).
func chunked(s string, r *rand.Rand, max int) []string {
var out []string
for len(s) > 0 {
n := 1 + r.Intn(max)
if n > len(s) {
n = len(s)
}
out = append(out, s[:n])
s = s[n:]
}
return out
}
// checkArgPreview rejoue args morceau par morceau et compare à chaque étape
// argPreview au previewArgDone historique — à une barre oblique finale près,
// qu'argPreview garde en attente au lieu de l'écrire.
func checkArgPreview(t *testing.T, args, key string, chunks []string) {
t.Helper()
a := newArgPreview(key)
prefix := ""
for _, c := range chunks {
prefix += c
a.update(prefix)
want, wantDone := previewArgDone(prefix, key)
got := a.value()
if a.phase == 3 && a.pos == len(prefix)-1 && prefix[a.pos] == '\\' {
got += "\\"
}
if got != want || a.done() != wantDone {
t.Fatalf("args=%q préfixe=%q : argPreview=(%q,%v), previewArgDone=(%q,%v)", args, prefix, got, a.done(), want, wantDone)
}
if n := strings.Count(a.value(), "\n"); n != a.lines {
t.Fatalf("lines=%d, la valeur en compte %d", a.lines, n)
}
if n := bodyLineCount(a.value()); n != a.lineCount() {
t.Fatalf("lineCount=%d, bodyLineCount=%d (valeur %q)", a.lineCount(), n, a.value())
}
}
}
func TestArgPreviewMatchesPreviewArgDone(t *testing.T) {
r := rand.New(rand.NewSource(7))
alphabet := []string{"a", "é", "\n", "\t", "\r", "\\", "\"", "<", "&", "
", "/", " ", "x", "content", ":", "{"}
var cases []string
for i := 0; i < 300; i++ {
var b strings.Builder
for j := r.Intn(200); j > 0; j-- {
b.WriteString(alphabet[r.Intn(len(alphabet))])
}
v := b.String()
m, _ := json.Marshal(map[string]string{"file": "a/b.go", "content": v})
cases = append(cases, string(m))
// Ordre inverse et clé citée avant la vraie : même règle de première occurrence.
cases = append(cases, `{"content" : "`+strings.ReplaceAll(v, `"`, `\"`)+`","file":"x"}`)
cases = append(cases, `{"note":"le champ \"content\" vient après","content":"`+strings.ReplaceAll(v, `"`, `\"`)+`"}`)
}
cases = append(cases,
`{"content":"été \\u0041 fin"}`,
`{"content":"ligne1\nligne2\\`,
`{"content":`, `{"cont`, `{"content":"`, `{"content":"x"}`, ``,
)
for _, args := range cases {
for _, max := range []int{1, 3, 17, 4096} {
checkArgPreview(t, args, "content", chunked(args, r, max))
}
}
}
func FuzzArgPreview(f *testing.F) {
f.Add(`{"file":"a","content":"x\ny\\\"zA"}`, int64(1))
f.Add(`{"content":"a\\`, int64(2))
f.Add(`{"content" : "x", "content":"y"}`, int64(3))
f.Fuzz(func(t *testing.T, args string, seed int64) {
checkArgPreview(t, args, "content", chunked(args, rand.New(rand.NewSource(seed)), 9))
})
}
func TestBodyTail(t *testing.T) {
lines := func(n int, w int) string {
var b strings.Builder
for i := 0; i < n; i++ {
b.WriteString(strings.Repeat("x", w) + strconv.Itoa(i) + "\n")
}
return b.String()
}
long := strings.Repeat("é", 5000) // une seule ligne de 10 000 octets
for _, tc := range []struct {
name string
in string
cut bool
maxLine int
}{
{"vide", "", false, 0},
{"court", "a\nb\nc", false, 3},
{"40 lignes pile", lines(39, 3) + "fin", false, 40},
{"beaucoup de lignes", lines(500, 3) + "fin", true, bodyTailLines},
{"lignes larges", lines(100, 300), true, bodyTailLines},
{"ligne seule énorme", long, true, 1},
} {
got, cut := bodyTail(tc.in)
if cut != tc.cut || !strings.HasSuffix(tc.in, got) {
t.Errorf("%s : cut=%v, suffixe=%v", tc.name, cut, strings.HasSuffix(tc.in, got))
}
if len(got) > bodyTailBytes {
t.Errorf("%s : %d octets", tc.name, len(got))
}
if n := strings.Count(got, "\n") + 1; got != "" && n > tc.maxLine {
t.Errorf("%s : %d lignes", tc.name, n)
}
if cut {
if start := len(tc.in) - len(got); tc.in[start-1] != '\n' && !strings.Contains(tc.name, "seule") {
t.Errorf("%s : coupé en milieu de ligne", tc.name)
}
if got != "" && !json.Valid([]byte(strconv.Quote(got))) || strings.ContainsRune(got, '�') {
t.Errorf("%s : caractère coupé", tc.name)
}
}
}
}
func TestBodyLineCount(t *testing.T) {
for in, want := range map[string]int{"": 0, "\n": 0, "a": 1, "a\n": 1, "a\nb": 2, "a\nb\n": 2, "\n\n": 2} {
if got := bodyLineCount(in); got != want {
t.Errorf("bodyLineCount(%q) = %d, attendu %d", in, got, want)
}
}
}
// Les événements de frappe d'un gros write ne portent qu'une fin bornée du
// corps, avec le vrai nombre de lignes ; les arguments exécutés restent entiers.
func TestWriteTypingBodyBounded(t *testing.T) {
var content strings.Builder
for i := 0; i < 3000; i++ {
content.WriteString("ligne numéro " + strconv.Itoa(i) + " du fichier\n")
}
withWorkspace(t)
args, _ := json.Marshal(map[string]string{"file": "gros.txt", "content": content.String()})
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
if atomic.AddInt32(&calls, 1) > 1 {
_, _ = w.Write([]byte(sseChunk("fini")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
return
}
s := string(args)
for len(s) > 0 {
n := 37
if n > len(s) {
n = len(s)
}
for n < len(s) && !utf8.RuneStart(s[n]) {
n++
}
piece, _ := json.Marshal(s[:n])
s = s[n:]
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"w1","type":"function","function":{"name":"write","arguments":` + string(piece) + `}}]}}]}` + "\n\n"))
}
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\ndata: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
var sent, last int
var lastEv *ToolUsedEvent
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "écris"}}, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
if tu := ev.ToolUsed; tu != nil && tu.Typing && tu.Body != "" {
if len(tu.Body) > bodyTailBytes {
t.Fatalf("corps de %d octets dans un événement de frappe", len(tu.Body))
}
if tu.BodyLines < last {
t.Fatalf("body_lines recule : %d après %d", tu.BodyLines, last)
}
last = tu.BodyLines
sent += len(tu.Body)
lastEv = tu
}
return true
})
if err != nil {
t.Fatal(err)
}
if lastEv == nil || !lastEv.BodyTail || lastEv.BodyLines < 2990 {
t.Fatalf("dernier événement = %+v", lastEv)
}
if !strings.HasSuffix(content.String(), lastEv.Body) {
t.Fatalf("la fin diffusée n'est pas la fin du corps : %q", lastEv.Body)
}
// Linéaire : 3000 lignes × 4 Kio au plus, loin des centaines de Mo d'avant.
if sent > 3000*bodyTailBytes {
t.Fatalf("%d octets de corps diffusés", sent)
}
// Ce qui est exécuté, lui, est entier.
if b, err := os.ReadFile(filepath.Join(agentCwd(), "gros.txt")); err != nil || string(b) != content.String() {
t.Fatalf("fichier écrit : %d octets, err=%v", len(b), err)
}
}
// replayScan rejoue le balayage du flux : un morceau sans caractère déclencheur
// n'est pas balayé. Renvoie l'indice du morceau qui coupe le flux, -1 sinon.
func replayScan(chunks []string, windowed bool) int {
var b strings.Builder
scanned := 0
for i, c := range chunks {
b.WriteString(c)
if !strings.ContainsAny(c, "<`{[_.") {
continue
}
s := b.String()
var hit bool
if windowed {
hit = textualToolCallFrom(s, scanned)
scanned = len(s)
} else {
hit = textualToolCall(s)
}
if hit {
return i
}
}
return -1
}
func TestTextualToolCallWindowMatchesFullScan(t *testing.T) {
r := rand.New(rand.NewSource(11))
prose := []string{
"Voici la suite de l'analyse, ligne par ligne. ",
"Le fichier contient trois fonctions ; aucune n'est exportée.\n",
"```go\nfunc main() { fmt.Println(\"x\") }\n```\n",
"- point important\n",
"le JSON {\"name\": \"loki\"} de config, ",
"sans ponctuation du tout mais avec des mots très longs ",
"\n\n",
"Un <div class=\"x\"> en HTML. ",
"été à la plage ",
}
filler := func(n int) string {
var b strings.Builder
for b.Len() < n {
b.WriteString(prose[r.Intn(len(prose))])
}
return b.String()
}
bad := []string{
"<tool_call>{\"name\":\"bash\"}</tool_call>",
"\n{\"name\": \"bash\", \"arguments\": {}}",
"\n \n {\"name\":" + strings.Repeat(" \n", 400) + "\"mem_" + strings.Repeat("a", 700) + "\"",
"\n" + strings.Repeat(" ", 900) + "{\"name\": \"web_open\"",
"<function=" + strings.Repeat("b", 900) + ">",
" functions." + strings.Repeat("c", 600) + strings.Repeat(" ", 300) + "(",
"Je lance default_api:bash ",
"```tool_code\nprint(1)\n```",
"<parameter=command>",
"[TOOL_REQUEST]",
}
neutral := []string{
" milieu de ligne {\"name\": \"bash\"} ", // `^` : seulement en début de ligne
strings.Repeat("x", 800) + " {\"name\": \"bash\"",
"<tool_cal> presque",
}
var texts []string
for i := 0; i < 120; i++ {
head := filler(500 + r.Intn(5000))
tail := filler(r.Intn(1500))
switch i % 3 {
case 0:
texts = append(texts, head+bad[r.Intn(len(bad))]+tail)
case 1:
texts = append(texts, head+neutral[r.Intn(len(neutral))]+tail)
default:
texts = append(texts, head+tail)
}
}
// Motif à cheval sur un morceau sans caractère déclencheur.
texts = append(texts, filler(3000)+"<tool"+"_call"+">"+" puis du texte sans rien")
hits, misses := 0, 0
for _, s := range texts {
for _, max := range []int{3, 23, 200} {
ch := chunked(s, r, max)
if s == texts[len(texts)-1] {
ch = append(chunked(s[:len(s)-len("<tool_call> puis du texte sans rien")], r, max), "<tool", "_call", ">", " puis du texte", " sans rien")
}
w, f := replayScan(ch, true), replayScan(ch, false)
if w != f {
t.Fatalf("fenêtré coupe au morceau %d, balayage complet au %d (taille %d, max %d)", w, f, len(s), max)
}
if f >= 0 {
hits++
} else {
misses++
}
}
}
if hits == 0 || misses == 0 {
t.Fatalf("corpus déséquilibré : %d coupures, %d sans", hits, misses)
}
}
// La fenêtre est bien une fenêtre sur un texte ordinaire, et démarre en début
// de ligne.
func TestRetryScanStartWindows(t *testing.T) {
s := strings.Repeat("Une phrase ordinaire, avec sa ponctuation.\n", 500)
start := retryScanStart(s, len(s))
if start == 0 || len(s)-start > 2*retryScanMargin {
t.Fatalf("fenêtre de %d octets sur %d", len(s)-start, len(s))
}
if s[start-1] != '\n' {
t.Fatal("la fenêtre ne démarre pas en début de ligne")
}
// Une ligne sans fin plus longue que le recul permis : balayage complet.
if got := retryScanStart(strings.Repeat("a b ", 20000), 80000); got != 0 {
t.Fatalf("start = %d, attendu 0", got)
}
}
// Surcharge retry_patterns : ses motifs ne sont pas bornés, donc pas de fenêtre.
func TestTextualToolCallFromCustomFullScan(t *testing.T) {
retryPatterns()
saved := retryRes
savedCustom := retryCustom
defer func() { retryRes, retryCustom = saved, savedCustom }()
retryRes, retryCustom = []*regexp.Regexp{regexp.MustCompile(`(?mi)\Aoups`)}, true
s := "oups.\n" + strings.Repeat("Une phrase, puis une autre.\n", 400)
if !textualToolCallFrom(s, len(s)-10) {
t.Fatal("motif ancré en tête manqué")
}
retryCustom = false
if textualToolCallFrom(s, len(s)-10) {
t.Fatal("la fenêtre aurait dû le manquer (le test ne prouve rien sinon)")
}
}
// sseStream : un lot = une écriture, les événements dans l'ordre, et un lot
// trop gros est découpé.
type countingRW struct {
*httptest.ResponseRecorder
writes int
}
func (c *countingRW) Write(b []byte) (int, error) {
c.writes++
return c.ResponseRecorder.Write(b)
}
func TestSSEStreamBatches(t *testing.T) {
rw := &countingRW{ResponseRecorder: httptest.NewRecorder()}
s := &sseStream{w: rw, mu: &sync.Mutex{}, frame: func(m map[string]any) ([]byte, bool) {
return []byte("data: " + strconv.Itoa(m["seq"].(int)) + "\n\n"), true
}}
for i := 1; i <= 10; i++ {
if !s.queue(map[string]any{"seq": i}) {
t.Fatal("queue")
}
}
if rw.writes != 0 {
t.Fatalf("%d écritures avant flush", rw.writes)
}
s.flush()
if rw.writes != 1 {
t.Fatalf("%d écritures pour un lot", rw.writes)
}
for i := 11; i <= 10+2*sseBatchEvents; i++ {
s.queue(map[string]any{"seq": i})
}
s.emit(map[string]any{"seq": 11 + 2*sseBatchEvents})
if rw.writes != 4 {
t.Fatalf("%d écritures, attendu 4", rw.writes)
}
var want strings.Builder
for i := 1; i <= 11+2*sseBatchEvents; i++ {
want.WriteString("data: " + strconv.Itoa(i) + "\n\n")
}
if rw.Body.String() != want.String() {
t.Fatal("ordre des événements perdu")
}
}
// Le direct passe par queue puis flush, dans l'ordre des seq.
func TestSubscribeSinkLiveBatchOrder(t *testing.T) {
c := newTestConv()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var mu sync.Mutex
var seqs []int
flushed := 0
caught := make(chan struct{}, 1)
sink := tailSink{
emit: func(m map[string]any) bool {
if _, ok := m["caught_up"]; ok {
caught <- struct{}{}
}
return true
},
queue: func(m map[string]any) bool {
mu.Lock()
if s, ok := m["seq"].(int); ok {
seqs = append(seqs, s)
}
mu.Unlock()
return true
},
flush: func() bool { mu.Lock(); flushed++; mu.Unlock(); return true },
}
go c.subscribeSink(ctx, 0, -1, "", sink)
<-caught
const n = 500
for i := 0; i < n; i++ {
c.appendDelta(c.epoch, map[string]any{"content": "t"})
}
deadline := time.Now().Add(5 * time.Second)
for {
mu.Lock()
got, f := len(seqs), flushed
mu.Unlock()
if got == n && f > 0 {
break
}
if time.Now().After(deadline) {
t.Fatalf("%d/%d événements reçus", got, n)
}
time.Sleep(5 * time.Millisecond)
}
mu.Lock()
defer mu.Unlock()
for i, s := range seqs {
if s != i+1 {
t.Fatalf("seq %d en position %d", s, i)
}
}
}
func BenchmarkWriteTypingPreview(b *testing.B) {
var content strings.Builder
for content.Len() < 120<<10 {
content.WriteString("une ligne de code assez ordinaire, avec \"guillemets\"\n")
}
args, _ := json.Marshal(map[string]string{"file": "x", "content": content.String()})
s := string(args)
b.ResetTimer()
for i := 0; i < b.N; i++ {
a := newArgPreview("content")
last := -1
for end := 8; end <= len(s); end += 8 {
a.update(s[:end])
if a.lines > last {
last = a.lines
bodyTail(a.value())
}
}
}
}
func BenchmarkTextualToolCallWindowed(b *testing.B) {
s := strings.Repeat("Une phrase ordinaire, avec sa ponctuation. Et un point.\n", 1500)
b.ResetTimer()
for i := 0; i < b.N; i++ {
textualToolCallFrom(s, len(s)-8)
}
}
+24
View File
@@ -102,3 +102,27 @@ func modelTotalSize(path string) int64 {
}
return 0
}
// engineIOSample : octets lus du disque (read_bytes) et mémoire résidente du
// moteur, pour l'indication « possible thrash » du benchmark. ok=false si le
// PID ou /proc est illisible : le bench s'en passe sans rien dire.
func engineIOSample() (read, rss int64, ok bool) {
pid := readServicePID()
if pid <= 0 {
return 0, 0, false
}
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/io")
if err != nil {
return 0, 0, false
}
for _, line := range strings.Split(string(b), "\n") {
if v, found := strings.CutPrefix(line, "read_bytes:"); found {
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
return 0, 0, false
}
return n, procVmRSS(pid), true
}
}
return 0, 0, false
}
+3
View File
@@ -5,3 +5,6 @@ package loki
// Hors Linux, pas de /proc/<pid>/io : le pourcentage de chargement n'est pas
// mesurable, l'UI garde « chargement… » sans chiffre.
func engineLoadPct() int { return -1 }
// engineIOSample : pas de /proc/<pid>/io, pas d'indication « possible thrash ».
func engineIOSample() (read, rss int64, ok bool) { return 0, 0, false }
+3
View File
@@ -254,6 +254,9 @@ func refreshToolPath() {}
// supervises llama-server directly, as the old start.sh did with `exec`).
// args[0] must be the binary path.
func execServer(bin string, args []string) error {
// exec remplace le process sans rien exécuter après : ce qui attendait
// l'écrivain de la discussion (chat_persist.go) serait perdu.
flushPersister(persistExitWait)
return syscall.Exec(bin, args, os.Environ())
}
+1
View File
@@ -42,6 +42,7 @@ func scheduleAppRestart() (bool, string) {
go func() {
time.Sleep(1500 * time.Millisecond) // laisser la réponse atteindre le navigateur
flushPersister(persistExitWait) // écritures différées de la discussion
os.Exit(0)
}()
return true, "Loki redémarre — la page se reconnectera toute seule dans quelques secondes."
+25 -3
View File
@@ -70,10 +70,32 @@ var configTemplate = []struct{ key, help string }{
{"NGL", "couches déportées sur le GPU (999 ou auto = ce qui tient en VRAM ; all = tout, quitte à saturer)"},
{"BATCH", "batch (défaut 2048)"},
{"UBATCH", "micro-batch (défaut 512)"},
{"THREADS", "threads CPU, 0 = auto"},
{"THREADS_BATCH", "threads CPU du prefill, 0 = auto"},
{"KV_TYPE", "quantization du cache KV (q8_0, q4_0…) ; KV_TYPE_K / KV_TYPE_V pour les séparer"},
{"THREADS", "threads CPU ; vide ou 0 = auto (cœurs physiques ; cœurs P seulement sur Intel hybride Linux)"},
{"THREADS_BATCH", "threads CPU du prefill et de la vérification spéculative (MTP) ; vide ou 0 = comme THREADS"},
{"FIT_TARGET", "Mio laissés libres par carte par le placement auto (--fit-target), ex. 1024,3072 dans l'ordre des cartes ; " +
"vide = 1024, minimum 1024 ; sans effet si NGL chiffré, --tensor-split, -ot ou --n-cpu-moe"},
{"OP_OFFLOAD_MIN_BATCH", "experts MoE sur CPU : taille de lot à partir de laquelle ils sont recopiés vers le GPU ; " +
"vide = défaut du moteur (32) ; plus haut = lots courts sur CPU, à mesurer"},
{"CUDA_GRAPH_OPT", "on = GGML_CUDA_GRAPH_OPT=1, branches Q/K/V en parallèle au décodage (expérimental, " +
"sortie identique, gain de 0 à quelques %) ; off puis redémarrage s'il plante"},
{"KV_TYPE", "quantization du cache KV ; vide = f16, sorties de référence ; q8_0 les modifie légèrement, " +
"q4_0 avec perte mesurable ; KV_TYPE_K / KV_TYPE_V pour les séparer"},
{"CACHE_RAM", "cache de prompts en RAM hôte (--cache-ram, Mio), copie exacte des conversations quittées, jamais la VRAM ; " +
"vide = auto (agrandi seulement pour un modèle tout-GPU), -1 = sans limite, 0 = coupé"},
{"CACHE_ISOLATE", "off = ne plus effacer le slot après un sous-agent, une vérification, une tâche ou un bench"},
{"CTX_CHECKPOINTS", "points de reprise par slot d'un modèle hybride (--ctx-checkpoints) ; vide = défaut du moteur (32) ; " +
"chacun pèse 70 à 200 Mio de RAM hôte : 8 à 16 si le modèle remplit déjà la RAM"},
{"CKPT_MIN_STEP", "espacement minimal en jetons entre deux points de reprise (--checkpoint-min-step), > 0 ; " +
"vide = défaut du moteur, ou 2048 d'office sur un hybride avec un moteur officiel antérieur à b10864"},
{"SPEC", "décodage spéculatif, sortie inchangée : off (défaut) / auto = tête MTP du modèle ou MODEL_DRAFT si --fit place " +
"tout et qu'aucun essai n'a échoué / mtp = imposé ; ~1-2 Go de VRAM en plus"},
{"MODEL_DRAFT", "tête MTP publiée à part (mtp-*.gguf) ou petit modèle brouillon, nom ou chemin comme MODEL ; utilisé si SPEC≠off"},
{"SPEC_N_MAX", "jetons anticipés par étape (--spec-draft-n-max) ; vide = défaut du moteur (3)"},
{"SPEC_SAMPLING", "tirage du brouillon : greedy (défaut, exact) ; probabilistic seulement avec SPEC=mtp"},
{"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"},
{"REASONING_PRESERVE", "on/off = garder ou non la réflexion des tours passés dans le gabarit (--reasoning-preserve) ; " +
"vide = défaut du moteur (on depuis b10763). Loki ne renvoie pas cette réflexion : off rend l'ancien historique " +
"aux gabarits type Qwen3.6, mais change celui des gabarits qui la gardent d'eux-mêmes (Qwen3.8)"},
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"},
{"TEMP", "température d'échantillonnage ; vide = défaut du moteur"},
+187
View File
@@ -0,0 +1,187 @@
package loki
// sys_storagefuse.go — conseil de PERFORMANCE pour Unraid : /data et /models
// servis par la couche FUSE des partages (fuse.shfs).
//
// Sur Unraid, /mnt/user/… n'est pas un disque mais un démon en espace
// utilisateur (shfs) qui agrège array et pools. Tout y passe : chaque fsync de
// loki.db, et surtout chaque page du .gguf que le noyau doit relire quand un
// gros MoE mmappé ne tient pas en RAM — pendant le décodage, à chaque token.
// Rien de perdu, rien d'altéré (les octets du modèle sont les mêmes), mais tout
// est plus lent.
//
// Loki ne déplace RIEN : changer le chemin hôte d'une installation existante
// donnerait un /data vide et ferait croire à une perte de données (voir le
// README). Il se contente de le VOIR et de le DIRE, sur un ton d'information —
// pas le bandeau rouge de sys_datavolume.go, réservé à la perte de données. Le
// remède sans changement de chemin : des partages en « Exclusive access »
// (Unraid 6.12+). Ce n'est pas une case à cocher mais un état : « Permit
// exclusive shares » activé, partage tout entier sur un pool, sans stockage
// secondaire — /mnt/user/<partage> devient alors un lien symbolique vers le
// pool, que Docker résout au démarrage du conteneur.
import (
"bufio"
"io"
"os"
"path"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
// mountEntry : ce qu'on retient d'une ligne de /proc/self/mountinfo.
type mountEntry struct {
point string // point de montage (5ᵉ champ), échappements décodés
fsType string // type de système de fichiers (1ᵉʳ champ après « - »)
}
// parseMountinfo lit le format de /proc/<pid>/mountinfo. Le nombre de champs
// optionnels (shared:N, master:N…) varie d'une ligne à l'autre : le type de
// système de fichiers se lit donc APRÈS le séparateur « - », jamais à un index
// fixe. Les lignes malformées sont ignorées.
func parseMountinfo(r io.Reader) []mountEntry {
var out []mountEntry
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 64*1024), 1024*1024)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 5 {
continue
}
sep := -1
for i := 5; i < len(fields); i++ {
if fields[i] == "-" {
sep = i
break
}
}
if sep < 0 || sep+1 >= len(fields) {
continue
}
out = append(out, mountEntry{
point: path.Clean(unescapeMountinfo(fields[4])),
fsType: fields[sep+1],
})
}
return out
}
// unescapeMountinfo décode les séquences octales du noyau (\040 espace,
// \011 tabulation, \012 saut de ligne, \134 antislash).
func unescapeMountinfo(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+3 < len(s) {
if v, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil {
b.WriteByte(byte(v))
i += 3
continue
}
}
b.WriteByte(s[i])
}
return b.String()
}
// mountFSType renvoie le type de système de fichiers qui porte path : celui du
// point de montage le plus LONG qui le contient (/data/models peut être un
// montage imbriqué distinct de /data). À longueur égale, la dernière ligne
// gagne — c'est le montage empilé par-dessus, celui qu'on voit vraiment.
// "" quand aucun montage ne couvre le chemin. Chemins Linux : package path, et
// non filepath, pour que les tests lisent la même chose sous Windows.
func mountFSType(mounts []mountEntry, target string) string {
m, _ := mountOf(mounts, target)
return m.fsType
}
// mountOf renvoie le montage qui porte target (règle de mountFSType) ; false
// quand aucun ne le couvre.
func mountOf(mounts []mountEntry, target string) (mountEntry, bool) {
p := path.Clean(target)
best, found := -1, mountEntry{}
for _, m := range mounts {
mp := m.point
if mp != "/" && p != mp && !strings.HasPrefix(p, mp+"/") {
continue
}
if len(mp) >= best {
best, found = len(mp), m
}
}
return found, best >= 0
}
// shfsPaths renvoie, parmi paths, ceux qui passent par fuse.shfs, dans l'ordre
// reçu et un seul par montage : /data/models, qui vit sur le montage de /data,
// n'apprendrait rien de plus à l'utilisateur et allongerait le conseil.
func shfsPaths(mounts []mountEntry, paths []string) []string {
var out []string
seen := map[string]bool{}
for _, p := range paths {
if strings.TrimSpace(p) == "" {
continue
}
m, ok := mountOf(mounts, p)
if !ok || m.fsType != "fuse.shfs" || seen[m.point] {
continue
}
seen[m.point] = true
out = append(out, path.Clean(p))
}
return out
}
// storageHintText formule le conseil pour les chemins concernés ("" si aucun).
// Conseil uniquement : les fichiers sont intacts, seule la vitesse en pâtit.
func storageHintText(paths []string) string {
if len(paths) == 0 {
return ""
}
return "ℹ️ Stockage via la couche FUSE des partages Unraid (fuse.shfs) : " + strings.Join(paths, ", ") + ". " +
"Les écritures de la base et les pages du modèle relues depuis le disque (gros modèle qui ne tient pas en RAM) " +
"traversent un démon en espace utilisateur, ce qui ralentit le décodage. Rien n'est perdu. " +
"Remède sans changer de chemin (Unraid 6.12+) : Global Share Settings → « Permit exclusive shares » = Yes, " +
"partages appdata/modèles entièrement sur un pool sans stockage secondaire, jusqu'à lire « Exclusive access : Yes », " +
"puis redémarrer le conteneur. Voir docker-compose.unraid.yml."
}
// storageHint mis en cache : /api/status est interrogé en boucle, et la liste
// des dossiers de modèles passe par la base. Les montages ne bougent pas pendant
// la vie du conteneur ; une minute suffit à refléter un dossier ajouté dans l'UI.
var storageHintCache struct {
sync.Mutex
at time.Time
val string
}
// storageFuseHint renvoie le conseil « /data ou /models sur fuse.shfs » ("" si
// sans objet). Linux en conteneur seulement, comme dataVolumeWarning : hors
// conteneur, l'utilisateur gère ses chemins.
func storageFuseHint() string {
if runtime.GOOS != "linux" || os.Getenv("LOKI_CONTAINER") == "" {
return ""
}
c := &storageHintCache
c.Lock()
defer c.Unlock()
if !c.at.IsZero() && time.Since(c.at) < time.Minute {
return c.val
}
c.at = time.Now()
c.val = ""
f, err := os.Open("/proc/self/mountinfo")
if err != nil {
return "" // illisible : on ne conseille rien plutôt que de deviner
}
defer f.Close()
mounts := parseMountinfo(f)
paths := append([]string{LokiHome()}, modelDirs()...)
c.val = storageHintText(shfsPaths(mounts, paths))
return c.val
}
+107
View File
@@ -0,0 +1,107 @@
package loki
import (
"runtime"
"strings"
"testing"
)
// Extrait réaliste d'un conteneur Loki sur Unraid : racine overlay, /data et
// /models en bind depuis /mnt/user (fuse.shfs), un /data/models imbriqué sur
// un pool btrfs, champs optionnels de longueur variable, un chemin à espace.
const mountinfoUnraid = `612 553 0:156 / / rw,relatime master:221 - overlay overlay rw,lowerdir=/var/lib/docker/l
613 612 0:159 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw
620 612 0:52 /appdata/loki/data /data rw,noatime - fuse.shfs shfs rw,user_id=0,group_id=0,allow_other
621 612 0:52 /appdata/loki/models /models rw,noatime shared:5 master:7 - fuse.shfs shfs rw,user_id=0
622 620 0:61 /appdata/loki/fast /data/models rw,noatime - btrfs /dev/nvme0n1p1 rw,ssd
623 612 0:52 /media /mnt/mes\040modeles rw,noatime - fuse.shfs shfs rw
garbage line
624 612 8:1 / /broken rw
`
func TestParseMountinfo(t *testing.T) {
m := parseMountinfo(strings.NewReader(mountinfoUnraid))
if len(m) != 6 {
t.Fatalf("attendu 6 montages valides, obtenu %d : %+v", len(m), m)
}
// Champs optionnels (shared:5 master:7) : le type se lit après « - ».
if m[3].point != "/models" || m[3].fsType != "fuse.shfs" {
t.Fatalf("ligne à champs optionnels mal lue : %+v", m[3])
}
if m[5].point != "/mnt/mes modeles" {
t.Fatalf("échappement \\040 non décodé : %q", m[5].point)
}
}
func TestMountFSType(t *testing.T) {
m := parseMountinfo(strings.NewReader(mountinfoUnraid))
cases := []struct {
path, want string
}{
{"/data", "fuse.shfs"},
{"/data/loki.db", "fuse.shfs"},
{"/data/models", "btrfs"}, // montage imbriqué : le plus long gagne
{"/data/models/x.gguf", "btrfs"},
{"/data/modelsX", "fuse.shfs"}, // préfixe de chaîne ≠ préfixe de chemin
{"/models", "fuse.shfs"},
{"/mnt/mes modeles/a.gguf", "fuse.shfs"},
{"/opt/loki", "overlay"},
}
for _, c := range cases {
if got := mountFSType(m, c.path); got != c.want {
t.Errorf("mountFSType(%q) = %q, attendu %q", c.path, got, c.want)
}
}
if got := mountFSType(nil, "/data"); got != "" {
t.Errorf("sans montage connu : %q, attendu \"\"", got)
}
}
func TestShfsPaths(t *testing.T) {
m := parseMountinfo(strings.NewReader(mountinfoUnraid))
got := shfsPaths(m, []string{"/data", "/data/models", "/models", "/models/", "/opt"})
want := []string{"/data", "/models"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("shfsPaths = %v, attendu %v", got, want)
}
// Un sous-dossier du même montage (LOKI_MODEL_DIRS=/data/gguf) n'est pas
// répété : un seul chemin par montage fuse.shfs.
got = shfsPaths(m, []string{"/data/gguf", "/data", "/mnt/mes modeles/a"})
want = []string{"/data/gguf", "/mnt/mes modeles/a"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("shfsPaths (même montage) = %v, attendu %v", got, want)
}
// Partages en « Exclusive access » : plus de fuse.shfs, plus de conseil.
excl := `1 0 0:1 / / rw - overlay overlay rw
2 1 0:2 /appdata/loki/data /data rw - btrfs /dev/nvme0n1p1 rw
3 1 0:3 /loki/models /models rw - xfs /dev/md1p1 rw
`
if p := shfsPaths(parseMountinfo(strings.NewReader(excl)), []string{"/data", "/models"}); len(p) != 0 {
t.Fatalf("aucun chemin ne devrait être signalé, obtenu %v", p)
}
}
func TestStorageHintText(t *testing.T) {
if s := storageHintText(nil); s != "" {
t.Fatalf("rien à signaler : %q", s)
}
s := storageHintText([]string{"/data", "/models"})
for _, w := range []string{"/data, /models", "fuse.shfs", "Permit exclusive shares", "Exclusive access", "Rien n'est perdu"} {
if !strings.Contains(s, w) {
t.Errorf("conseil sans %q : %s", w, s)
}
}
}
func TestStorageFuseHintOutsideContainer(t *testing.T) {
t.Setenv("LOKI_CONTAINER", "")
if s := storageFuseHint(); s != "" {
t.Fatalf("hors conteneur, aucun conseil attendu : %q", s)
}
if runtime.GOOS != "linux" {
t.Setenv("LOKI_CONTAINER", "1")
if s := storageFuseHint(); s != "" {
t.Fatalf("hors Linux, aucun conseil attendu : %q", s)
}
}
}
+1
View File
@@ -50,6 +50,7 @@ func runTray(url string) {
// que seul root peut piloter. Fermer une interface n'a pas à arrêter un
// service système. Sous Windows, où l'app est propriétaire du moteur,
// « Quitter » l'arrête bel et bien (voir sys_tray_windows.go).
flushPersister(persistExitWait) // écritures différées de la discussion
os.Exit(0)
})
}
+1
View File
@@ -53,6 +53,7 @@ func runTray(url string) {
// Linux et macOS c'est systemd ou launchd, et fermer une interface n'a
// pas à arrêter un service système.
_ = svcStop(false)
flushPersister(persistExitWait) // écritures différées de la discussion
os.Exit(0)
})
}
+1
View File
@@ -493,6 +493,7 @@ func restartAfterUpdate() (bool, string) {
}
go func() {
time.Sleep(1500 * time.Millisecond) // laisser la réponse HTTP atteindre le client
flushPersister(persistExitWait) // systemd nous arrêtera : écritures différées de la discussion d'abord
// Le service tourne souvent en User=nathan (pas root) : systemctl brut
// échouerait alors (polkit). On repli sur « sudo -n systemctl » comme
// uiServiceCtl, les sudoers /etc/sudoers.d/loki-ui autorisant le restart.
+11 -5
View File
@@ -69,9 +69,11 @@ func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prom
temperature = 0.7
}
// La note de tâche (conscience du mode autonome + compte-rendu du passage
// précédent) va EN TÊTE DU MESSAGE UTILISATEUR, pas dans le système : le
// préfixe système + outils reste ainsi identique à celui du chat, et le cache
// de prompt de llama-server sert aux deux. Une note dans le système faisait
// précédent) va EN TÊTE DU MESSAGE UTILISATEUR, pas dans le système : elle
// n'ajoute aucun écart au préfixe système + outils. Il n'est pas pour autant
// celui du chat : le briefing machine porte le dossier de travail de la
// tâche, et taskCaps ne reprend ni le pilotage du navigateur ni une mémoire
// coupée. Une note dans le système faisait
// tout recalculer à la tâche, PUIS au message suivant de l'utilisateur (12 s
// mesurées en amont pour un simple « salut », AJEAN 0.17.5).
if note := taskContextNote(taskName, lastReport); note != "" {
@@ -79,7 +81,7 @@ func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prom
}
msgs := []Message{{Role: "user", Content: prompt}}
// Même préambule que la vraie génération : consigne personnelle + préambule
// agent + briefing machine (via InjectSkills), pour que l'IA ait le même
// agent + briefing machine (via prepareTurn), pour que l'IA ait le même
// contexte et les mêmes outils qu'en chat.
// Contexte du projet de la tâche : le projet est déjà forcé par l'appelant
// (setProjectOverride dans runTask), donc ces messages décrivent le bon
@@ -90,7 +92,11 @@ func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prom
}
var content strings.Builder
_, err := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
// La tâche a pris le slot de la conversation : effacé une fois la tâche
// finie, verrou de génération encore tenu (llm_slots.go).
defer engineSideJob()()
sent, tools := prepareTurn(final, caps)
_, err := runChatTools(withPerf(ctx, perfTask, "task:"+taskID), sent, tools, temperature, caps, func(ev StreamEvent) bool {
if ev.Content != "" {
content.WriteString(ev.Content)
}
+18 -4
View File
@@ -45,13 +45,23 @@ func toolResID(sid string) string {
// saveToolResult enregistre un résultat complet de la discussion ACTIVE et
// renvoie son id ("" en cas d'échec : l'appelant envoie alors le résultat
// entier dans le flux).
//
// L'écriture est confiée à l'écrivain de la discussion (chat_persist.go) : un
// tour d'agent qui coupe vingt résultats payait vingt fsync entre l'outil et
// l'étape suivante. Le résultat est servi depuis la mémoire jusqu'à ce qu'il
// soit sur disque (loadToolResult).
func saveToolResult(result string) string {
id := toolResID(getStr(bkChat, ckActive))
// Chiffrement actif mais mémoire verrouillée : putStoreBytes refuse d'écrire
// en clair, et l'appelant envoie alors le résultat entier dans le flux.
if id == "" || putStoreBytes(bkToolRes, id, []byte(result)) != nil {
id := toolResID(convActiveID())
// Chiffrement actif mais mémoire verrouillée : on refuse d'écrire en clair,
// et l'appelant envoie alors le résultat entier dans le flux. Vérifié ICI,
// pas à l'écriture : un id rendu doit désigner un résultat qu'on écrira.
if id == "" {
return ""
}
if _, err := memEncoderNow(); err != nil {
return ""
}
persistQ.enqueueToolRes(toolResJob{path: dbPath(), key: id, plain: result})
if toolResWrites.Add(1)%toolResPruneGap == 0 {
go pruneToolResults()
}
@@ -63,6 +73,9 @@ func loadToolResult(id string) (string, bool) {
if strings.ContainsAny(id, "/\\") {
return "", false
}
if s, ok := persistQ.toolResPending(id); ok {
return s, true
}
b, err := getStoreBytesErr(bkToolRes, id)
if err != nil || b == nil {
return "", false
@@ -76,6 +89,7 @@ func deleteToolResultsFor(sid string) {
return
}
prefix := sid + "."
persistQ.dropToolRes(prefix)
_ = update(bkToolRes, func(b *bolt.Bucket) error {
c := b.Cursor()
var keys [][]byte
+1
View File
@@ -55,6 +55,7 @@ func TestToolResultsChiffres(t *testing.T) {
if id == "" {
t.Fatal("résultat non enregistré")
}
persistQ.flush() // l'écriture est différée (chat_persist.go)
if !looksEncrypted(getBytes(bkToolRes, id)) {
t.Fatal("résultat en clair alors que la mémoire est chiffrée")
}
+13 -1
View File
@@ -107,7 +107,19 @@ func trackerList() []trackerMeta {
}
out = append(out, m)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].LastTS > out[j].LastTS })
// Départage par nom puis slug : allKV parcourt une map, et deux trackers au
// même horodatage (deux points datés du même jour, sans heure) sortaient dans
// un ordre tiré au sort. Cette liste part dans le contexte du modèle — un ordre qui
// change d'un tour à l'autre change le prompt, et tout le cache derrière.
sort.SliceStable(out, func(i, j int) bool {
if out[i].LastTS != out[j].LastTS {
return out[i].LastTS > out[j].LastTS
}
if out[i].Name != out[j].Name {
return out[i].Name < out[j].Name
}
return out[i].Slug < out[j].Slug
})
return out
}
+29
View File
@@ -289,3 +289,32 @@ func TestStrTolerantAuType(t *testing.T) {
t.Fatalf("str(\"texte\") = %q", got)
}
}
// Deux trackers au même horodatage (points datés du même jour, sans heure)
// sortent toujours dans le même ordre — par nom — quel que soit l'ordre de
// parcours du stockage. La liste part dans le contexte du modèle : un ordre
// tiré au sort changerait le prompt d'un tour à l'autre.
func TestTrackerListOrdreStableAEgalite(t *testing.T) {
newProjectHome(t)
ensureDefaultProject()
for _, name := range []string{"zinc", "azote", "magnésium"} {
if _, err := trackerAdd(name, "2026-05-04", "1"); err != nil {
t.Fatal(err)
}
}
if _, err := trackerAdd("récent", "2026-06-01", "2"); err != nil {
t.Fatal(err)
}
want := []string{"récent", "azote", "magnésium", "zinc"}
for round := 0; round < 20; round++ {
list := trackerList()
var got []string
for _, m := range list {
got = append(got, m.Name)
}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("ordre %v, attendu %v (le plus récent d'abord, puis par nom)", got, want)
}
}
}
+397 -70
View File
@@ -1151,6 +1151,9 @@ h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
#chat .msg .statline{margin-top:10px;font-family:var(--mono);font-size:10.5px;letter-spacing:.02em;
color:var(--dim);opacity:.75}
#chat .msg .statline:empty{display:none}
/* Cache de prompts perdu (gros recalcul) : la vitesse passe en ambre, la
ligne reste discrète mais ne se lit plus comme un tour ordinaire. */
#chat .msg .statline.cache-miss>.spd{color:var(--warn)}
/* « Masquer la vitesse de génération » ne masque QUE la vitesse : le temps de
travail du tour (.worktime) n'est pas une mesure de moteur, c'est ce que la
réponse a coûté. Une ligne qui ne portait que de la vitesse disparaît en
@@ -2298,6 +2301,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</details>
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
<!-- Conseil de performance (stockage Unraid via fuse.shfs) : permanent tant
que la cause dure, donc masquable — mémorisé pour CE texte seulement. -->
<div id="app-hint" class="muted" style="display:none;position:relative;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 24px 8px 10px;border-radius:4px;border:1px solid var(--border)"><span id="app-hint-text"></span><button type="button" onclick="dismissHint()" title="masquer ce conseil" aria-label="masquer ce conseil" style="position:absolute;top:3px;right:3px;margin:0;padding:0 5px;border:0;background:none;color:inherit;font-size:14px;line-height:1.3">×</button></div>
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
<!-- Journal du moteur : replié par défaut, ouvert au clic sur la pastille d'état
OU par son propre chevron — c'est une section comme les autres, sinon on ne
@@ -2465,7 +2471,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div>
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></div>
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
<button id="bench-cancel" onclick="cancelBenchUI()" style="display:none">annuler</button>
<button onclick="closeBenchModal()">fermer</button>
<button id="bench-full" onclick="runBenchUI('full')" title="prefill à froid en profondeur puis 3 tours qui reprennent le cache — 1 à 5 minutes">bench complet</button>
<button id="bench-rerun" onclick="runBenchUI()" style="border-color:var(--accent);color:var(--accent)">relancer</button>
</div>
</div>
@@ -2763,6 +2771,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<div style="font-size:12px;line-height:1.5">
<div>Moteur <b>llama.cpp</b> <span id="eng-build" class="mcp-tag eng-ver"></span> <span id="eng-source" class="mcp-tag"></span></div>
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
<div id="eng-notice" class="muted" style="margin-top:6px;display:none"></div>
<div id="eng-latest" style="margin-top:6px"></div>
<div class="row" style="margin:8px 0 0">
<!-- « vérifier » ne fait QUE regarder (aucun téléchargement, aucune
@@ -2771,6 +2780,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<button onclick="engCheck()">vérifier les mises à jour</button>
<button onclick="engUpdate()">mettre à jour le moteur</button>
<button id="eng-revert" style="display:none" onclick="engUse('image')">revenir au moteur de l'image</button>
<span id="eng-others"></span>
</div>
<div class="muted" style="margin-top:6px">Télécharge llama-server et ses bibliothèques depuis l'image officielle
<code id="eng-repo"></code> (~170&nbsp;Mo), sans reconstruire l'image de Loki. Le nouveau moteur est essayé
@@ -3437,16 +3447,16 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non</span></span>
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent)</span></span>
<span class="pe-row-c"><input id="s-moe" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="eaSetValued('--n-cpu-moe', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Threads CPU<span class="pe-sub">0 = auto</span></span>
<span class="pe-row-c"><input id="s-threads" class="pe-val" type="number" min="0" step="1" placeholder="0" oninput="cfgWriteKey('THREADS', this.value)"></span>
<span class="pe-row-l">Threads CPU<span class="pe-sub">vide ou 0 = cœurs physiques</span></span>
<span class="pe-row-c"><input id="s-threads" class="pe-val" type="number" min="0" step="1" placeholder="auto" oninput="cfgWriteKey('THREADS', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Threads (batch)<span class="pe-sub">0 = auto</span></span>
<span class="pe-row-c"><input id="s-tbatch" class="pe-val" type="number" min="0" step="1" placeholder="0" oninput="cfgWriteKey('THREADS_BATCH', this.value)"></span>
<span class="pe-row-l">Threads (batch)<span class="pe-sub">prefill et MTP · vide ou 0 = comme Threads CPU</span></span>
<span class="pe-row-c"><input id="s-tbatch" class="pe-val" type="number" min="0" step="1" placeholder="auto" oninput="cfgWriteKey('THREADS_BATCH', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">BATCH</span>
@@ -3457,25 +3467,38 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<span class="pe-row-c"><input id="s-ubatch" class="pe-val" type="number" min="0" step="128" placeholder="512" oninput="cfgWriteKey('UBATCH', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Cache KV<span class="pe-sub">compression du contexte</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-kv" onchange="cfgWriteKey('KV_TYPE', this.value)">
<!-- Cache de prompts en RAM hôte (--cache-ram, Mio) : copie exacte de
l'état, rechargée quand la conversation revient. Vide = auto, la
valeur calculée s'affiche en indication (refreshCacheRAM). -->
<span class="pe-row-l">Cache de prompts (Mio)<span class="pe-sub" id="s-cram-sub">RAM hôte, copie exacte du contexte · jamais la VRAM</span></span>
<span class="pe-row-c"><input id="s-cram" class="pe-val" type="number" min="-1" step="1024" placeholder="auto" oninput="cfgWriteKey('CACHE_RAM', this.value)"></span>
</div>
<div class="pe-row">
<!-- Seul f16 laisse les calculs du modèle intacts : chaque autre
option le dit. Le sous-titre montre le type EFFECTIF — un
-ctk/-ctv d'EXTRA_ARGS l'emporte sur cette liste (syncKVSub). -->
<span class="pe-row-l">Cache KV<span class="pe-sub" id="s-kv-sub">compression du contexte</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-kv" onchange="cfgWriteKey('KV_TYPE', this.value); syncKVSub()">
<option value="">f16 (max qualité)</option>
<option value="q8_0">q8_0 (léger)</option>
<option value="q4_0">q4_0 (très léger)</option>
<option value="q5_1">q5_1 (lent sur CUDA)</option>
<option value="bf16">bf16</option>
<option value="q8_0">q8_0 (léger · modifie légèrement les sorties)</option>
<option value="q4_0">q4_0 (très léger · perte mesurable)</option>
<option value="q5_1">q5_1 (lent sur CUDA · modifie les sorties)</option>
<option value="bf16">bf16 (numérique différente de f16)</option>
</select></span></span>
</div>
<!-- Décodage spéculatif : le type vient de la liste que llama-server
accepte pour --spec-type. Le nombre de jetons anticipés
<!-- Décodage spéculatif : « auto » et « MTP » écrivent la clé SPEC,
que Loki traduit au lancement (tête MTP détectée dans le fichier,
garde-fous VRAM) ; les autres types vont tels quels dans
--spec-type. « non » écrit SPEC=off. Le nombre de jetons anticipés
(--spec-draft-n-max) n'a de sens qu'une fois un type choisi, donc
sa ligne n'apparaît qu'à ce moment. -->
<div class="pe-row">
<span class="pe-row-l">Décodage spéculatif<span class="pe-sub">anticipe des jetons pour accélérer</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-spec" onchange="onSpecType()">
<option value="">non</option>
<option value="auto">auto (MTP si le modèle en a une et que la VRAM suit)</option>
<optgroup label="Modèle brouillon">
<option value="draft-mtp">MTP (intégré au modèle)</option>
<option value="draft-mtp">MTP (imposé)</option>
<option value="draft-eagle3">EAGLE-3</option>
<option value="draft-simple">modèle brouillon séparé</option>
<option value="draft-dflash">dFlash</option>
@@ -3494,6 +3517,12 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<span class="pe-row-l">Jetons anticipés<span class="pe-sub">par étape — défaut 3</span></span>
<span class="pe-row-c"><input id="s-spec-n" class="pe-val" type="number" min="1" step="1" placeholder="3" oninput="eaSetValued('--spec-draft-n-max', this.value)"></span>
</div>
<!-- Tête MTP publiée à part (mtp-*.gguf) ou petit modèle brouillon :
clé MODEL_DRAFT, résolue au lancement comme le projecteur vision. -->
<div class="pe-row" id="s-draft-row" style="display:none">
<span class="pe-row-l">Brouillon<span class="pe-sub">tête MTP à part ou petit modèle</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-draft" onchange="onPickDraft()"></select></span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Raisonnement<span class="pe-sub" id="s-reasoning-sub">réflexion étape par étape</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-reasoning" onchange="cfgWriteKey('REASONING', this.checked?'on':'off')"><span class="pe-sl"></span></label></span>
@@ -3576,7 +3605,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<span class="pe-row-c"><span id="m-raw-caret" class="pe-caret"></span></span>
</div>
<div id="m-raw-body" class="pe-row stack" style="display:none">
<textarea id="m-content" class="pe-inp" spellcheck="false"></textarea>
<textarea id="m-content" class="pe-inp" spellcheck="false" oninput="syncKVSub()"></textarea>
</div>
</div>
</div>
@@ -3919,8 +3948,31 @@ async function jfetch(u, opts){
const k = await askKeyOnce();
if(k){ opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); }
}
// 403 de la garde anti-site-tiers (web_auth.go, crossSiteReject) : sans ce
// bandeau, l'interface restait vide sans dire pourquoi — chaque appel échouait
// en silence dans la console.
if(r.status === 403 && !accessBlockedShown){
r.clone().json().then(j=>{ if(j && j.error) showAccessBlocked(j.error); }).catch(()=>{});
}
return r;
}
// showAccessBlocked : bandeau fixe expliquant le refus d'accès et comment le
// lever (nom de domaine derrière un reverse proxy, sans clé de pilotage).
let accessBlockedShown = false;
function showAccessBlocked(msg){
if(accessBlockedShown) return;
accessBlockedShown = true;
const d = document.createElement('div');
d.id = 'access-blocked';
d.setAttribute('role', 'alert');
d.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;padding:12px 16px;background:var(--err,#b3564d);color:#fff;font-size:13px;line-height:1.5';
const host = location.hostname;
d.innerHTML = '<b>Accès refusé par Loki</b> — ' + escHtml(msg) +
'<br>Derrière un reverse proxy : ajoute <code>LOKI_TRUSTED_HOSTS=' + escHtml(host) +
'</code> aux variables du conteneur puis recrée-le, ou définis une clé de pilotage (<code>docker exec -it loki loki set-web-key</code>).';
document.body.appendChild(d);
}
// askKeyOnce : UNE seule demande de clé à la fois, partagée par tous les appels.
//
// Le chargement de l'UI lance une dizaine d'appels /api/* en parallèle, et le
@@ -3995,6 +4047,20 @@ async function loadStatus(){
if(s.warn){ wb.textContent='⚠ '+s.warn; wb.style.display=''; }
else { wb.style.display='none'; }
}
// Conseil de performance (stockage Unraid via fuse.shfs) : encart discret,
// pas le bandeau d'alerte — rien n'est perdu, seul le décodage ralentit.
// Il reste affiché tant que la cause dure : masquable, et seulement pour CE
// texte — un autre conseil (autre chemin concerné) réapparaît.
const hb=document.getElementById('app-hint');
if(hb){
let gone='';
try{ gone=localStorage.getItem('loki-hint-dismissed')||''; }catch(e){}
if(s.hint && s.hint!==gone){
document.getElementById('app-hint-text').textContent=s.hint;
hb.dataset.hint=s.hint; hb.style.display='';
}
else { hb.style.display='none'; }
}
// Modèle qui ne charge pas (souvent un moteur incompatible) : message explicite
// plutôt qu'un « chargement… » perpétuel ou un crash-loop muet.
const me=document.getElementById('model-err');
@@ -4003,6 +4069,14 @@ async function loadStatus(){
else { me.style.display='none'; }
}
}
// Masque le conseil de stockage pour ce navigateur (simple confort : sans
// localStorage, il revient au prochain rafraîchissement, rien de plus).
function dismissHint(){
const hb=document.getElementById('app-hint');
if(!hb) return;
try{ localStorage.setItem('loki-hint-dismissed', hb.dataset.hint||''); }catch(e){}
hb.style.display='none';
}
// Journal du moteur — section repliable comme les autres. La pastille d'état
// reste un raccourci ; le chargement du journal est déclenché par l'événement
// `toggle` du <details>, qu'on l'ouvre par la pastille ou par son chevron.
@@ -4363,8 +4437,11 @@ async function loadPresets(){
}
if(x.bench){
const bt=document.createElement('span'); bt.className='btag';
bt.title='prefill / decode — dernier bench de ce preset';
bt.textContent=x.bench.prefill.toFixed(0)+'-'+x.bench.decode.toFixed(0)+' t/s';
let tip='prefill / decode — dernier bench de ce preset';
if(x.bench.depth_decode) tip+='\ndecode à '+x.bench.depth+' jetons : '+x.bench.depth_decode.toFixed(1)+' t/s';
if(x.bench.kv) tip+='\ncache KV '+x.bench.kv+(x.bench.kv_quantized?' — quantifié (zone grise, pas une référence sans perte)':'');
bt.title=tip;
bt.textContent=x.bench.prefill.toFixed(0)+'-'+x.bench.decode.toFixed(0)+' t/s'+(x.bench.kv_quantized?' · KV '+x.bench.kv:'');
meta.appendChild(bt);
}
info.appendChild(nm);
@@ -4891,46 +4968,101 @@ async function loadAll(){
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
function openBenchModal(){ showModal('bench-modal'); }
function closeBenchModal(){ hideModal('bench-modal'); }
async function runBenchUI(){
const btn = document.getElementById('btn-bench');
const rerun = document.getElementById('bench-rerun');
const body = document.getElementById('bench-body');
openBenchModal();
btn.disabled = true; btn.textContent = '⏳ bench…';
rerun.disabled = true;
body.innerHTML =
'<div style="text-align:center;padding:20px 0">' +
// Le bench tourne en tâche de fond côté serveur (llm_bench_job.go) : on le lance,
// puis on suit sa progression. Fermer la fenêtre ne l'arrête pas ; la rouvrir
// (bouton bench) reprend le suivi au lieu d'en lancer un second.
let benchPoll=null;
function benchButtons(busy){
const btn=document.getElementById('btn-bench');
btn.disabled=busy; btn.textContent=busy?'⏳ bench…':'bench';
for(const id of ['bench-rerun','bench-full']){ const b=document.getElementById(id); if(b) b.disabled=busy; }
const c=document.getElementById('bench-cancel'); if(c) c.style.display=busy?'':'none';
}
function benchSpinner(text){
return '<div style="text-align:center;padding:20px 0">' +
'<div style="font-size:24px;animation:spin 1s linear infinite;display:inline-block">⏳</div>' +
'<div class="muted" style="margin-top:8px">prompt 2000 tok + 300 decode<br>~10 secondes…</div>' +
'</div>';
'<div class="muted" style="margin-top:8px">'+text+'</div></div>';
}
function benchErr(msg){ return '<div style="color:var(--err);text-align:center">erreur : '+escHtml(msg||'?')+'</div>'; }
async function runBenchUI(mode){
mode = mode==='full' ? 'full' : 'quick';
const body=document.getElementById('bench-body');
openBenchModal();
benchButtons(true);
body.innerHTML=benchSpinner('démarrage…');
try{
const r = await jget('/api/bench');
if(!r.ok){
body.innerHTML = '<div style="color:var(--err);text-align:center">erreur: '+r.error+'</div>';
const st=await jget('/api/bench/status');
if(st.running){ benchWatch(); return; }
}catch(e){}
let r;
try{ r=await jpost('/api/bench',{mode:mode}); }catch(e){ r={ok:false,error:e.message}; }
if(!r.ok){ body.innerHTML=benchErr(r.error); benchButtons(false); return; }
benchWatch();
}
async function cancelBenchUI(){ try{ await jpost('/api/bench/cancel',{}); }catch(e){} }
function benchWatch(){
clearTimeout(benchPoll);
const body=document.getElementById('bench-body');
const tick=async()=>{
let st;
try{ st=await jget('/api/bench/status'); }
catch(e){ benchPoll=setTimeout(tick,2000); return; }
if(st.running){
const step=st.steps?' ('+st.step+'/'+st.steps+')':'';
const hint=st.mode==='full'?'<br>bench complet : 1 à 5 minutes, chat et tâches en pause':'';
body.innerHTML=benchSpinner(escHtml(st.phase||'…')+step+' · '+Math.round(st.elapsed_sec||0)+' s'+hint);
benchPoll=setTimeout(tick,1000);
return;
}
const x = r.result;
body.innerHTML =
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;text-align:center">' +
'<div style="padding:14px;background:var(--panel);border:1px solid var(--border);border-radius:8px">' +
'<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.1em">Prefill</div>' +
'<div style="font-size:26px;color:var(--accent);font-weight:600;margin:6px 0">'+x.prompt_per_second.toFixed(0)+'</div>' +
'<div class="muted">tok/s</div>' +
'<div class="muted" style="font-size:11px;margin-top:8px">'+x.prompt_n+' tok · '+(x.prompt_ms/1000).toFixed(2)+'s</div>' +
'</div>' +
'<div style="padding:14px;background:var(--panel);border:1px solid var(--border);border-radius:8px">' +
'<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.1em">Decode</div>' +
'<div style="font-size:26px;color:var(--ok);font-weight:600;margin:6px 0">'+x.predicted_per_second.toFixed(1)+'</div>' +
'<div class="muted">tok/s</div>' +
'<div class="muted" style="font-size:11px;margin-top:8px">'+x.predicted_n+' tok · '+(x.predicted_ms/1000).toFixed(2)+'s</div>' +
'</div>' +
'</div>' +
'<div class="muted" style="text-align:center;font-size:11px">total '+x.elapsed_sec.toFixed(2)+'s</div>';
} finally {
btn.disabled = false; btn.textContent = 'bench';
rerun.disabled = false;
benchButtons(false);
if(st.error) body.innerHTML=benchErr(st.error);
else if(st.result) body.innerHTML=benchResultHTML(st.result, st.saved);
else body.innerHTML='';
loadPresets();
};
tick();
}
function benchDraftTxt(d){ return d&&d.n ? Math.round(d.accepted*100/d.n)+' % ('+d.accepted+'/'+d.n+')' : 'n/a'; }
function benchCard(label, color, val, unit, sub){
return '<div style="padding:14px;background:var(--panel);border:1px solid var(--border);border-radius:8px">' +
'<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.1em">'+label+'</div>' +
'<div style="font-size:26px;color:'+color+';font-weight:600;margin:6px 0">'+val+'</div>' +
'<div class="muted">'+unit+'</div>' +
'<div class="muted" style="font-size:11px;margin-top:8px">'+sub+'</div></div>';
}
function benchResultHTML(x, saved){
let h='<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;text-align:center">' +
benchCard('Prefill','var(--accent)',x.prompt_per_second.toFixed(0),'tok/s',x.prompt_n+' tok · '+(x.prompt_ms/1000).toFixed(2)+'s') +
benchCard('Decode','var(--ok)',x.predicted_per_second.toFixed(1),'tok/s',x.predicted_n+' tok · '+(x.predicted_ms/1000).toFixed(2)+'s') +
'</div>';
const rows=[];
if(x.draft) rows.push(['brouillon accepté (prose)', benchDraftTxt(x.draft)]);
const d=x.depth;
const notes=[];
if(d){
if(d.skipped) notes.push('profondeur sautée : '+d.skipped);
if(d.cold) rows.push(['prefill à froid · '+d.cold.new+' tok', d.cold.prompt_per_second.toFixed(0)+' tok/s']);
if(d.turns&&d.turns.length){
rows.push(['prefill des tours suivants (cache)', (d.cached_per_second||0).toFixed(0)+' tok/s']);
rows.push(['decode en profondeur', (d.decode_per_second||0).toFixed(1)+' tok/s']);
rows.push(['reprise du cache', d.reuse>=0 ? Math.round(d.reuse*100)+' %' : 'n/a']);
rows.push(['brouillon accepté (code)', benchDraftTxt(d.draft)]);
}
for(const n of [d.reuse_note, d.partial, d.hint]) if(n) notes.push(n);
}
if(rows.length){
h+='<table style="width:100%;font-size:12px;border-collapse:collapse">';
for(const r of rows) h+='<tr><td class="muted" style="padding:3px 0">'+escHtml(r[0])+'</td><td style="text-align:right">'+escHtml(r[1])+'</td></tr>';
h+='</table>';
}
for(const n of notes) h+='<div style="font-size:11px;color:var(--warn)">'+escHtml(n)+'</div>';
let foot='KV '+(x.kv||'?')+(x.kv_quantized?' (quantifié — zone grise)':'');
if(x.engine) foot+=' · moteur '+x.engine;
foot+=' · total '+x.elapsed_sec.toFixed(1)+'s';
if(saved===false) foot+=' · résultat partiel, non enregistré';
h+='<div class="muted" style="text-align:center;font-size:11px">'+escHtml(foot)+'</div>';
if(d&&d.cold) h+='<div class="muted" style="text-align:center;font-size:11px">Le slot est effacé après le bench quand le moteur le permet : sinon, le prochain message reprend la conversation depuis le cache RAM (CACHE_RAM) ou la recalcule.</div>';
return h;
}
async function switchTo(n,name,id){
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
@@ -5056,7 +5188,7 @@ async function openItem(kind, key){
document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings();
attachDownload(); // téléchargement encore en cours côté serveur ?
await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(),
await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(), populateDraft(),
populateDlDirs(), refreshInstalled()]);
if(seq !== openSeq) return;
}
@@ -5414,7 +5546,7 @@ async function deleteInstalled(m){
}
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('modèle supprimé — ' + fmtSize(r.freed||0) + ' libérés');
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(),
await Promise.all([populateModelPicker(), populateMmproj(), populateDraft(), populateDlDirs(),
populateModelDirs(), refreshInstalled()]);
}
// Un .gguf donné est-il déjà sur le disque, complet ? Sert avant de proposer un
@@ -5747,7 +5879,7 @@ function cfgWriteKey(key, val){
// jeton par jeton pour les interrupteurs (mlock, flash-attn, n-cpu-moe…), sans
// toucher aux autres drapeaux (--jinja, --device…) qui restent en config brute.
function eaTokens(){ return cfgReadKey('EXTRA_ARGS').split(/\s+/).filter(Boolean); }
function eaSetTokens(t){ cfgWriteKey('EXTRA_ARGS', t.join(' ')); }
function eaSetTokens(t){ cfgWriteKey('EXTRA_ARGS', t.join(' ')); syncKVSub(); }
function eaHasFlag(flag){ return eaTokens().includes(flag); }
function eaToggleFlag(flag, on){
const t = eaTokens().filter(x=>x!==flag);
@@ -5773,8 +5905,11 @@ function populateSettings(){
set('s-threads', cfgReadKey('THREADS'));
set('s-batch', cfgReadKey('BATCH'));
set('s-ubatch', cfgReadKey('UBATCH'));
set('s-cram', cfgReadKey('CACHE_RAM'));
refreshCacheRAM();
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
set('s-kv', cfgReadKey('KV_TYPE'));
syncKVSub();
// Échantillonnage : envoyé PAR REQUÊTE (applySampling côté serveur), donc pas
// besoin de redémarrer le moteur pour qu'un changement prenne effet.
set('s-temp', cfgReadKey('TEMP'));
@@ -5785,7 +5920,7 @@ function populateSettings(){
set('s-reppen', cfgReadKey('REPEAT_PENALTY'));
set('s-moe', eaGetValued('--n-cpu-moe'));
set('s-spec-n', eaGetValued('--spec-draft-n-max'));
setSpecType(eaGetValued('--spec-type'));
setSpecType(eaGetValued('--spec-type') || specFromKey(cfgReadKey('SPEC')));
const chk = (id,v)=>{ const e=document.getElementById(id); if(e) e.checked=v; };
// Raisonnement : trois états dans le fichier, deux positions sur l'interrupteur.
// « off » est une interdiction explicite passée au moteur ; la clé ABSENTE, elle,
@@ -5822,6 +5957,80 @@ function populateSettings(){
set('s-loadmode', lm);
syncLoadModeSub(lm);
}
// Cache KV : le sous-titre dit ce que le moteur utilisera VRAIMENT, comme
// effectiveKVTypes côté Go. EXTRA_ARGS ferme la ligne de commande et la dernière
// occurrence gagne : un --cache-type-k q8_0 écrit à la main l'emporte sur la
// liste, qui afficherait sinon « f16 » pendant que le moteur tourne en q8_0.
// Les drapeaux de cache qui changent ce que voit le modèle (--context-shift,
// --cache-reuse > 0) sont signalés au même endroit. Rien n'est jamais modifié
// ici : c'est un affichage.
const KV_FIDELITY = {
'bf16': 'numérique différente de f16',
'q8_0': 'modifie légèrement les sorties', 'q5_0': 'modifie légèrement les sorties', 'q5_1': 'modifie légèrement les sorties',
'q4_0': 'perte mesurable', 'q4_1': 'perte mesurable', 'iq4_nl': 'perte mesurable',
};
const KV_RANK = {'':0, 'f16':0, 'f32':0, 'bf16':1, 'q4_0':3, 'q4_1':3, 'iq4_nl':3};
function eaLastValue(flags){
let v = '';
const t = eaTokens();
t.forEach((a, i) => {
const eq = a.indexOf('='), name = eq > 0 ? a.slice(0, eq) : a;
if(!flags.includes(name)) return;
if(eq > 0) v = a.slice(eq+1);
else if(i+1 < t.length) v = t[i+1];
});
return v;
}
// Valeur auto du cache de prompts, calculée côté serveur comme au lancement
// (VRAM, RAM libre, métadonnées du modèle) : affichée en indication du champ
// CACHE_RAM. Une seule demande en vol ; la dernière gagne.
let cramSeq = 0;
async function refreshCacheRAM(){
const el = document.getElementById('s-cram'), sub = document.getElementById('s-cram-sub');
if(!el) return;
const seq = ++cramSeq;
let r = {};
try{ r = await jpost('/api/preset/cacheram', {content: document.getElementById('m-content').value}); }
catch(_){ r = {}; }
if(seq !== cramSeq || !r.ok) return;
// Valeur fixée (CACHE_RAM, -cram, LLAMA_ARG_CACHE_RAM) : -1 et 0 ne sont pas
// « le défaut du moteur », la raison seule suffit.
const fixed = /^fixé/.test(r.why || '');
el.placeholder = fixed ? (r.mib < 0 ? 'sans limite' : r.mib === 0 ? 'coupé' : String(r.mib))
: 'auto · ' + (r.mib > 0 ? r.mib : r.default);
if(sub) sub.textContent = 'RAM hôte, copie exacte du contexte · jamais la VRAM' +
(r.why ? ' · ' + r.why : '') + (r.mib > 0 || fixed ? '' : ' · défaut du moteur');
}
function syncKVSub(){
const el = document.getElementById('s-kv-sub');
if(!el) return;
const kv = cfgReadKey('KV_TYPE');
const xk = eaLastValue(['-ctk','--cache-type-k']), xv = eaLastValue(['-ctv','--cache-type-v']);
const k = (xk || cfgReadKey('KV_TYPE_K') || kv).toLowerCase();
const v = (xv || cfgReadKey('KV_TYPE_V') || kv).toLowerCase();
const rank = x => (x in KV_RANK) ? KV_RANK[x] : 2;
const worst = rank(v) > rank(k) ? v : k;
const parts = [];
if(rank(worst) === 0){
parts.push(xk || xv ? 'f16 (défini par EXTRA_ARGS)' : 'compression du contexte');
} else {
const label = KV_FIDELITY[worst] || 'modifie les sorties';
parts.push((xk || xv ? 'défini par EXTRA_ARGS : ' : '') + (k||'f16')+'/'+(v||'f16')+' — '+label);
// Placement figé à la main : --fit ne tourne pas et n'absorbera pas le
// surplus d'un retour en f16. Comme tensorOverride côté Go : un
// --n-cpu-moe 0 ne place rien.
const placed = f => { const n = eaLastValue(f).trim(); return n !== '' && n !== '0'; };
if(eaTokens().some(a => ['-ot','--override-tensor','--cpu-moe','-cmoe'].includes(a.split('=')[0])) ||
placed(['--n-cpu-moe','-ncmoe']) || placed(['--n-cpu-ffn','-ncffn']))
parts.push('repasser en f16 : plus de VRAM, --n-cpu-moe à relever');
}
let shift = false;
eaTokens().forEach(a => { if(a === '--context-shift') shift = true; else if(a === '--no-context-shift') shift = false; });
if(shift) parts.push('--context-shift : jette des jetons quand le contexte est plein');
const reuse = parseInt(eaLastValue(['--cache-reuse']), 10);
if(reuse > 0) parts.push('--cache-reuse : cache réutilisé de façon approchée');
el.textContent = parts.join(' · ');
}
// Chargement du modèle. llama.cpp récent a REMPLACÉ --mlock / --no-mmap par
// --load-mode (auto, none, mmap, mlock, mmap+mlock, dio). L'éditeur écrit la
// forme moderne ; au lancement, loki serve la retraduit pour un moteur ancien
@@ -5860,7 +6069,14 @@ function syncLoadModeSub(mode){
const el = document.getElementById('s-loadmode-sub');
if(el) el.textContent = LOAD_MODE_SUB[mode] ?? mode;
}
// --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ----------------
// --- Décodage spéculatif (SPEC, MODEL_DRAFT, --spec-type, --spec-draft-n-max)
// « auto » et « MTP » passent par la clé SPEC : Loki cherche la tête MTP dans
// le fichier et pose les garde-fous au lancement. Les autres types restent des
// --spec-type d'EXTRA_ARGS, qui ont toujours le dernier mot côté serveur.
function specFromKey(v){
v = String(v||'').trim().toLowerCase();
return v === 'auto' ? 'auto' : v === 'mtp' ? 'draft-mtp' : '';
}
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
// par des virgules ; une valeur composée (ou un type sorti après cette version
// de loki) ne correspondrait à aucune option et serait silencieusement effacée
@@ -5886,17 +6102,58 @@ function syncSpecRow(){
if(!sel || !row) return;
const on = !!sel.value;
row.style.display = on ? '' : 'none';
// Le brouillon ne sert qu'à SPEC (auto / MTP) ; un brouillon déjà posé reste
// visible pour qu'on puisse le retirer.
const dr = document.getElementById('s-draft-row');
if(dr) dr.style.display = (sel.value === 'auto' || sel.value === 'draft-mtp' || cfgReadKey('MODEL_DRAFT')) ? '' : 'none';
return on;
}
function onSpecType(){
const v = document.getElementById('s-spec').value;
eaSetValued('--spec-type', v);
const viaKey = v === 'auto' || v === 'draft-mtp';
// « non » écrit SPEC=off : explicite, et identique au défaut du serveur.
cfgWriteKey('SPEC', v === 'auto' ? 'auto' : v === 'draft-mtp' ? 'mtp' : v ? '' : 'off');
eaSetValued('--spec-type', viaKey ? '' : v);
if(!v){
eaSetValued('--spec-draft-n-max', '');
document.getElementById('s-spec-n').value = '';
}
syncSpecRow();
}
// Liste des brouillons : tous les .gguf sauf les projecteurs vision. Une tête
// MTP publiée à part (mtp-*.gguf) est ce qu'on y choisira le plus souvent.
async function populateDraft(){
const sel = document.getElementById('m-draft');
if(!sel) return;
const list = await jget('/api/models');
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
const items = (list||[]).filter(m => !isMmprojName(m.name));
const hit = matchModel(cur, items);
let html = '<option value="">— aucun —</option>';
let matched = false;
for(const m of items){
const on = (m === hit) ? ' selected' : '';
if(on) matched = true;
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
}
if(cur && !matched){
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
}
sel.innerHTML = html;
syncSpecRow();
}
// Choisir un brouillon, c'est demander la spéculation : on passe en « auto » si
// elle était coupée (les garde-fous de Loki restent en place).
function onPickDraft(){
const v = document.getElementById('m-draft').value;
cfgWriteKey('MODEL_DRAFT', v);
const sel = document.getElementById('s-spec');
if(v && sel && !sel.value){ sel.value = 'auto'; onSpecType(); toast('décodage spéculatif : auto'); }
// Un ancien preset portait le MTP en --spec-type dans EXTRA_ARGS : sans clé
// SPEC, le brouillon serait ignoré au lancement. On le range dans SPEC=mtp.
else if(v && sel && sel.value === 'draft-mtp' && specFromKey(cfgReadKey('SPEC')) !== 'draft-mtp') onSpecType();
syncSpecRow();
}
// Replie/déplie l'éditeur du fichier .env (config brute) dans le modal preset.
function toggleRaw(){
@@ -6028,7 +6285,7 @@ let dlQueue = [];
// fichier. Le fichier a pu atterrir hors du dossier loki : l'option porte alors
// le chemin complet, pas le simple nom.
async function selectInstalled(fname){
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(), refreshInstalled()]);
await Promise.all([populateModelPicker(), populateMmproj(), populateDraft(), populateDlDirs(), refreshInstalled()]);
const pick = (id, cb)=>{
const s = document.getElementById(id);
if(!s) return false;
@@ -6036,6 +6293,7 @@ async function selectInstalled(fname){
if(!o) return false;
s.value = o.value; cb(); return true;
};
if(/^mtp-/i.test(fname)) return pick('m-draft', onPickDraft);
return isMmprojName(fname) ? pick('m-mmproj', onPickMmproj) : pick('m-model', onPickModel);
}
async function startDownload(){
@@ -6328,11 +6586,27 @@ async function hfPickRepo(repo){
}
o.appendChild(note);
// Tête MTP publiée à part (mtp-*.gguf) : même règle que le projecteur, elle
// ne vaut que pour SON modèle. Décochée : la spéculation reste un choix.
let dfChk = null;
const df = (r.drafts||[])[0] && hfBestProjector(r.drafts);
if(df){
const dn = document.createElement('label');
dn.className = 'hf-mm';
dfChk = document.createElement('input');
dfChk.type = 'checkbox';
const t = document.createElement('span');
t.textContent = ' installer aussi la tête MTP (décodage plus rapide, ~1-2 Go de VRAM) — '+df.name+' ('+fmtSize(df.size)+')'
+ (installed(df.name) ? ' — déjà sur le disque' : '');
dn.append(dfChk, t);
o.appendChild(dn);
}
const list = document.createElement('div'); list.className = 'hf-list';
for(const m of r.models){
const row = document.createElement('div');
row.className = 'hf-row';
row.onclick = ()=>hfInstall(m, mm && mmChk && mmChk.checked ? mm : null);
row.onclick = ()=>hfInstall(m, mm && mmChk && mmChk.checked ? mm : null, df && dfChk && dfChk.checked ? df : null);
const n = document.createElement('span'); n.className = 'hf-name';
n.textContent = m.quant || m.name;
const meta = document.createElement('span'); meta.className = 'hf-meta';
@@ -6379,14 +6653,14 @@ function hfBestProjector(list){
// Installe : le modèle d'abord, le projecteur ensuite (la file s'en charge). Le
// champ Vision du preset ne se remplit correctement que dans cet ordre.
async function hfInstall(model, projector){
async function hfInstall(model, projector, draft){
// Le fichier est là : rien à télécharger, donc rien à confirmer — la sonde le
// constate côté serveur et startDownloadURL le sélectionne.
const here = await modelIsOnDisk(model.name);
if(!here && model.verdict === 'trop' && !await askConfirm(
(model.why||'Ce modèle dépasse la mémoire disponible.')+'\n\nLe téléchargement fonctionnera, mais le moteur risque de ne pas le charger.',
{title:'Installer quand même ?', okText:'Installer', danger:true})) return;
dlQueue = projector ? [projector.url] : [];
dlQueue = [projector, draft].filter(Boolean).map(x => x.url);
document.getElementById('m-hf-url').value = model.url;
await startDownloadURL(model.url);
}
@@ -6711,7 +6985,9 @@ function renderToolMsg(el, tu){
let add=0, del=0;
if(tu.added!=null || tu.removed!=null){ add=tu.added||0; del=tu.removed||0; }
else if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); }
else if(tu.body){ add=bodyLineCount(tu.body); }
// Pendant la frappe, body n'est que la FIN du corps : body_lines porte le
// vrai total (repli sur le décompte pour un serveur d'avant ce champ).
else if(tu.body){ add=tu.body_lines!=null ? tu.body_lines : bodyLineCount(tu.body); }
if(add||del) setLabelCounts(el, add, del);
const body=bodyOf(el); body.innerHTML='';
const head=document.createElement('div'); head.className='tool-head';
@@ -6734,6 +7010,11 @@ function renderToolMsg(el, tu){
sub.textContent='écriture en cours'; // le +N vit sur l'étiquette (visible repliée)
body.appendChild(sub);
const pre=document.createElement('pre'); pre.className='diff live';
// Début omis par le serveur (seules les dernières lignes voyagent) : on le dit.
if(tu.body_tail){
const more=document.createElement('span'); more.className='dl'; more.textContent=' …';
pre.appendChild(more);
}
lines.forEach((t,i)=>{
const ln=document.createElement('span');
ln.className='dl add'+(i===lines.length-1?' fresh':'');
@@ -7213,16 +7494,35 @@ function renderStats(el, s){
if(!el||!s) return;
const parts=[];
const pt=s.prompt_tokens||s.prompt_tokens_total;
// Débit de lecture inconnu (API tierce sans timings) : on n'affiche que la taille.
if(pt) parts.push('prefill '+pt+' tok'+(s.prompt_per_second ? ' · '+s.prompt_per_second.toFixed(0)+' tok/s' : ''));
const pps=s.prompt_per_second ? ' · '+s.prompt_per_second.toFixed(0)+' tok/s' : '';
// Cache connu (cache_tokens présent, même à 0) : ce qui a été recalculé et ce
// qui venait du cache. Inconnu (moteur ancien, API tierce) : la taille seule,
// comme avant — un « 0 en cache » inventé serait pire que rien.
const cached=s.cache_tokens;
if(cached!=null && (s.prompt_tokens!=null || s.prompt_tokens_total)){
const fresh=s.prompt_tokens!=null ? s.prompt_tokens : Math.max(0,s.prompt_tokens_total-cached);
parts.push('prefill '+nfmt(fresh)+' nouveaux / '+nfmt(cached)+' en cache'+pps);
} else if(pt) parts.push('prefill '+pt+' tok'+pps); // débit inconnu (API tierce sans timings) : la taille seule
if(s.gen_tokens) parts.push('decode '+s.gen_tokens+' tok · '+(s.gen_per_second||0).toFixed(1)+' tok/s');
// Perte de cache signalée par le serveur (seuil relevé sur un modèle hybride,
// dont les points de reprise font perdre un peu à chaque étape).
if(s.lost_alert && s.lost) parts.push(nfmt(s.lost)+' recalculés'+(s.lost_after ? ' après '+s.lost_after.split(',').map(k=>PERF_KIND[k]||k).join(', ') : ''));
if(!parts.length) return;
// Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette
// est masquée dans cette mise en page). Bulle repliable : l'étiquette EST le
// bouton de repli, on y écrit comme avant.
if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(workLabel()||[], parts).join(' · '));
else paintStats(el, parts.join(' · '));
else{
paintStats(el, parts.join(' · '));
const sl=el.querySelector(':scope > .statline');
if(sl) sl.classList.toggle('cache-miss', !!s.lost_alert);
}
}
// Ce qui s'est intercalé avant une perte de cache (lost_after, perf_log.go),
// dit en clair : les identifiants internes n'ont rien à faire dans l'interface.
const PERF_KIND={main:'tour', subagent:'sous-agent', verify:'vérification', task:'tâche', compact:'compaction', bench:'bench', foreign:'client /v1'};
// Milliers séparés par une espace fine insécable : « 41 230 », pas « 41230 ».
function nfmt(n){ return String(Math.round(n||0)).replace(/\B(?=(\d{3})+(?!\d))/g,'\u202f'); }
// --- Compteurs de bulle -----------------------------------------------------
// Ils sont PAR BULLE, jamais par tour. Un tour d'agent en ouvre une nouvelle
// après CHAQUE appel d'outil (le flux repasse T.contentEl à null) ; les
@@ -7513,11 +7813,16 @@ function handleDelta(d){
if(d.compact_noop){ setCompacting(false); if(!REPLAYING) toast('rien à compacter (contexte déjà minimal)'); return; }
if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; }
if(d.stats){ T.serverStats=d.stats; feedConvSpeed(d.stats);
// Conseil ponctuel (cache de prompts trop petit) : en direct seulement.
if(d.stats.cache_hint && !REPLAYING) toast(d.stats.cache_hint);
// prompt_tokens_total = comptage exact du moteur (include_usage). Absent sur
// certains llama-server récents : le serveur publie alors un `ctx_used`
// estimé en fin de tour, traité plus bas — on ne remet donc PAS la jauge à
// zéro ici, on la laisse simplement inchangée.
if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); }
// zéro ici, on la laisse simplement inchangée. Même calcul que le serveur
// (StatsEvent.ctxAfter) : le raisonnement, jamais renvoyé au modèle, ne
// compte pas. ctx_isolated = passe du vérificateur, sur sa propre trace :
// ses chiffres ne disent rien de la discussion, la jauge ne bouge pas.
if(d.stats.prompt_tokens_total && !d.ctx_isolated){ const g=d.stats.gen_tokens||0; setCtxUsed(d.stats.prompt_tokens_total+g-Math.min(g, d.stats.reasoning_tokens||0)); }
if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }
if(d.tool_used){
settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé
@@ -7722,9 +8027,17 @@ async function send(){
fail('échec de l\'envoi — réessaie');
}
loadAll();
setInterval(loadStatus, 5000);
setInterval(loadVram, 3000);
setInterval(loadRam, 3000);
// Jauges et pastille d'état : rien à rafraîchir dans un onglet que personne ne
// regarde. Chaque tic lançait sinon un nvidia-smi côté serveur, pour chaque
// onglet ou téléphone laissé ouvert, pendant que le modèle génère. Au retour de
// l'onglet, une lecture immédiate remet tout à jour.
const pollVisible=(fn)=>()=>{ if(!document.hidden) fn(); };
setInterval(pollVisible(loadStatus), 5000);
setInterval(pollVisible(loadVram), 3000);
setInterval(pollVisible(loadRam), 3000);
document.addEventListener('visibilitychange', ()=>{
if(!document.hidden){ loadStatus(); loadVram(); loadRam(); }
});
// Backend llama.cpp — la barre latérale sert UNIQUEMENT à installer les moteurs.
// « llama.cpp précompilé » = binaires officiels, aucune compilation
// « llama.cpp compilé » = compilé ici, pour cette machine
@@ -7867,6 +8180,20 @@ async function loadEngine(){
// Le retour arrière n'a de sens que si on n'est pas déjà dessus.
const revert = document.getElementById('eng-revert');
if(revert) revert.style.display = (s.image_bin && s.source !== 'image') ? '' : 'none';
// Versions téléchargées gardées à côté (la précédente survit à une mise à
// jour) : revenir dessus ne demande ni réseau ni que le tag soit encore publié.
const others = document.getElementById('eng-others');
if(others) others.innerHTML = (s.installed || []).filter(v=>!v.in_use).map(v=>{
const tag = String(v.tag).replace(/[^A-Za-z0-9._-]/g,'');
return '<button onclick="engUse(\''+tag+'\')">utiliser '+(v.build ? 'b'+v.build : tag)+'</button>';
}).join(' ');
// Build trop ancien pour les points de reprise des hybrides (voir
// engineBuildNotice) : un avis, rien n'est changé d'office ici.
const notice = document.getElementById('eng-notice');
if(notice){
notice.textContent = s.notice ? '⚠ '+s.notice : '';
notice.style.display = s.notice ? '' : 'none';
}
engSay('');
}
+41 -15
View File
@@ -75,6 +75,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</details>
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
<!-- Conseil de performance (stockage Unraid via fuse.shfs) : permanent tant
que la cause dure, donc masquable — mémorisé pour CE texte seulement. -->
<div id="app-hint" class="muted" style="display:none;position:relative;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 24px 8px 10px;border-radius:4px;border:1px solid var(--border)"><span id="app-hint-text"></span><button type="button" onclick="dismissHint()" title="masquer ce conseil" aria-label="masquer ce conseil" style="position:absolute;top:3px;right:3px;margin:0;padding:0 5px;border:0;background:none;color:inherit;font-size:14px;line-height:1.3">×</button></div>
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
<!-- Journal du moteur : replié par défaut, ouvert au clic sur la pastille d'état
OU par son propre chevron — c'est une section comme les autres, sinon on ne
@@ -242,7 +245,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></div>
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
<button id="bench-cancel" onclick="cancelBenchUI()" style="display:none">annuler</button>
<button onclick="closeBenchModal()">fermer</button>
<button id="bench-full" onclick="runBenchUI('full')" title="prefill à froid en profondeur puis 3 tours qui reprennent le cache — 1 à 5 minutes">bench complet</button>
<button id="bench-rerun" onclick="runBenchUI()" style="border-color:var(--accent);color:var(--accent)">relancer</button>
</div>
</div>
@@ -540,6 +545,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<div style="font-size:12px;line-height:1.5">
<div>Moteur <b>llama.cpp</b> <span id="eng-build" class="mcp-tag eng-ver"></span> <span id="eng-source" class="mcp-tag"></span></div>
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
<div id="eng-notice" class="muted" style="margin-top:6px;display:none"></div>
<div id="eng-latest" style="margin-top:6px"></div>
<div class="row" style="margin:8px 0 0">
<!-- « vérifier » ne fait QUE regarder (aucun téléchargement, aucune
@@ -548,6 +554,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<button onclick="engCheck()">vérifier les mises à jour</button>
<button onclick="engUpdate()">mettre à jour le moteur</button>
<button id="eng-revert" style="display:none" onclick="engUse('image')">revenir au moteur de l'image</button>
<span id="eng-others"></span>
</div>
<div class="muted" style="margin-top:6px">Télécharge llama-server et ses bibliothèques depuis l'image officielle
<code id="eng-repo"></code> (~170&nbsp;Mo), sans reconstruire l'image de Loki. Le nouveau moteur est essayé
@@ -1214,16 +1221,16 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non</span></span>
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent)</span></span>
<span class="pe-row-c"><input id="s-moe" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="eaSetValued('--n-cpu-moe', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Threads CPU<span class="pe-sub">0 = auto</span></span>
<span class="pe-row-c"><input id="s-threads" class="pe-val" type="number" min="0" step="1" placeholder="0" oninput="cfgWriteKey('THREADS', this.value)"></span>
<span class="pe-row-l">Threads CPU<span class="pe-sub">vide ou 0 = cœurs physiques</span></span>
<span class="pe-row-c"><input id="s-threads" class="pe-val" type="number" min="0" step="1" placeholder="auto" oninput="cfgWriteKey('THREADS', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Threads (batch)<span class="pe-sub">0 = auto</span></span>
<span class="pe-row-c"><input id="s-tbatch" class="pe-val" type="number" min="0" step="1" placeholder="0" oninput="cfgWriteKey('THREADS_BATCH', this.value)"></span>
<span class="pe-row-l">Threads (batch)<span class="pe-sub">prefill et MTP · vide ou 0 = comme Threads CPU</span></span>
<span class="pe-row-c"><input id="s-tbatch" class="pe-val" type="number" min="0" step="1" placeholder="auto" oninput="cfgWriteKey('THREADS_BATCH', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">BATCH</span>
@@ -1234,25 +1241,38 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<span class="pe-row-c"><input id="s-ubatch" class="pe-val" type="number" min="0" step="128" placeholder="512" oninput="cfgWriteKey('UBATCH', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Cache KV<span class="pe-sub">compression du contexte</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-kv" onchange="cfgWriteKey('KV_TYPE', this.value)">
<!-- Cache de prompts en RAM hôte (--cache-ram, Mio) : copie exacte de
l'état, rechargée quand la conversation revient. Vide = auto, la
valeur calculée s'affiche en indication (refreshCacheRAM). -->
<span class="pe-row-l">Cache de prompts (Mio)<span class="pe-sub" id="s-cram-sub">RAM hôte, copie exacte du contexte · jamais la VRAM</span></span>
<span class="pe-row-c"><input id="s-cram" class="pe-val" type="number" min="-1" step="1024" placeholder="auto" oninput="cfgWriteKey('CACHE_RAM', this.value)"></span>
</div>
<div class="pe-row">
<!-- Seul f16 laisse les calculs du modèle intacts : chaque autre
option le dit. Le sous-titre montre le type EFFECTIF — un
-ctk/-ctv d'EXTRA_ARGS l'emporte sur cette liste (syncKVSub). -->
<span class="pe-row-l">Cache KV<span class="pe-sub" id="s-kv-sub">compression du contexte</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-kv" onchange="cfgWriteKey('KV_TYPE', this.value); syncKVSub()">
<option value="">f16 (max qualité)</option>
<option value="q8_0">q8_0 (léger)</option>
<option value="q4_0">q4_0 (très léger)</option>
<option value="q5_1">q5_1 (lent sur CUDA)</option>
<option value="bf16">bf16</option>
<option value="q8_0">q8_0 (léger · modifie légèrement les sorties)</option>
<option value="q4_0">q4_0 (très léger · perte mesurable)</option>
<option value="q5_1">q5_1 (lent sur CUDA · modifie les sorties)</option>
<option value="bf16">bf16 (numérique différente de f16)</option>
</select></span></span>
</div>
<!-- Décodage spéculatif : le type vient de la liste que llama-server
accepte pour --spec-type. Le nombre de jetons anticipés
<!-- Décodage spéculatif : « auto » et « MTP » écrivent la clé SPEC,
que Loki traduit au lancement (tête MTP détectée dans le fichier,
garde-fous VRAM) ; les autres types vont tels quels dans
--spec-type. « non » écrit SPEC=off. Le nombre de jetons anticipés
(--spec-draft-n-max) n'a de sens qu'une fois un type choisi, donc
sa ligne n'apparaît qu'à ce moment. -->
<div class="pe-row">
<span class="pe-row-l">Décodage spéculatif<span class="pe-sub">anticipe des jetons pour accélérer</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-spec" onchange="onSpecType()">
<option value="">non</option>
<option value="auto">auto (MTP si le modèle en a une et que la VRAM suit)</option>
<optgroup label="Modèle brouillon">
<option value="draft-mtp">MTP (intégré au modèle)</option>
<option value="draft-mtp">MTP (imposé)</option>
<option value="draft-eagle3">EAGLE-3</option>
<option value="draft-simple">modèle brouillon séparé</option>
<option value="draft-dflash">dFlash</option>
@@ -1271,6 +1291,12 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<span class="pe-row-l">Jetons anticipés<span class="pe-sub">par étape — défaut 3</span></span>
<span class="pe-row-c"><input id="s-spec-n" class="pe-val" type="number" min="1" step="1" placeholder="3" oninput="eaSetValued('--spec-draft-n-max', this.value)"></span>
</div>
<!-- Tête MTP publiée à part (mtp-*.gguf) ou petit modèle brouillon :
clé MODEL_DRAFT, résolue au lancement comme le projecteur vision. -->
<div class="pe-row" id="s-draft-row" style="display:none">
<span class="pe-row-l">Brouillon<span class="pe-sub">tête MTP à part ou petit modèle</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-draft" onchange="onPickDraft()"></select></span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Raisonnement<span class="pe-sub" id="s-reasoning-sub">réflexion étape par étape</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-reasoning" onchange="cfgWriteKey('REASONING', this.checked?'on':'off')"><span class="pe-sl"></span></label></span>
@@ -1353,7 +1379,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<span class="pe-row-c"><span id="m-raw-caret" class="pe-caret"></span></span>
</div>
<div id="m-raw-body" class="pe-row stack" style="display:none">
<textarea id="m-content" class="pe-inp" spellcheck="false"></textarea>
<textarea id="m-content" class="pe-inp" spellcheck="false" oninput="syncKVSub()"></textarea>
</div>
</div>
</div>
+23
View File
@@ -33,8 +33,31 @@ async function jfetch(u, opts){
const k = await askKeyOnce();
if(k){ opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); }
}
// 403 de la garde anti-site-tiers (web_auth.go, crossSiteReject) : sans ce
// bandeau, l'interface restait vide sans dire pourquoi — chaque appel échouait
// en silence dans la console.
if(r.status === 403 && !accessBlockedShown){
r.clone().json().then(j=>{ if(j && j.error) showAccessBlocked(j.error); }).catch(()=>{});
}
return r;
}
// showAccessBlocked : bandeau fixe expliquant le refus d'accès et comment le
// lever (nom de domaine derrière un reverse proxy, sans clé de pilotage).
let accessBlockedShown = false;
function showAccessBlocked(msg){
if(accessBlockedShown) return;
accessBlockedShown = true;
const d = document.createElement('div');
d.id = 'access-blocked';
d.setAttribute('role', 'alert');
d.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;padding:12px 16px;background:var(--err,#b3564d);color:#fff;font-size:13px;line-height:1.5';
const host = location.hostname;
d.innerHTML = '<b>Accès refusé par Loki</b> — ' + escHtml(msg) +
'<br>Derrière un reverse proxy : ajoute <code>LOKI_TRUSTED_HOSTS=' + escHtml(host) +
'</code> aux variables du conteneur puis recrée-le, ou définis une clé de pilotage (<code>docker exec -it loki loki set-web-key</code>).';
document.body.appendChild(d);
}
// askKeyOnce : UNE seule demande de clé à la fois, partagée par tous les appels.
//
// Le chargement de l'UI lance une dizaine d'appels /api/* en parallèle, et le
+22
View File
@@ -50,6 +50,20 @@ async function loadStatus(){
if(s.warn){ wb.textContent='⚠ '+s.warn; wb.style.display=''; }
else { wb.style.display='none'; }
}
// Conseil de performance (stockage Unraid via fuse.shfs) : encart discret,
// pas le bandeau d'alerte — rien n'est perdu, seul le décodage ralentit.
// Il reste affiché tant que la cause dure : masquable, et seulement pour CE
// texte — un autre conseil (autre chemin concerné) réapparaît.
const hb=document.getElementById('app-hint');
if(hb){
let gone='';
try{ gone=localStorage.getItem('loki-hint-dismissed')||''; }catch(e){}
if(s.hint && s.hint!==gone){
document.getElementById('app-hint-text').textContent=s.hint;
hb.dataset.hint=s.hint; hb.style.display='';
}
else { hb.style.display='none'; }
}
// Modèle qui ne charge pas (souvent un moteur incompatible) : message explicite
// plutôt qu'un « chargement… » perpétuel ou un crash-loop muet.
const me=document.getElementById('model-err');
@@ -58,6 +72,14 @@ async function loadStatus(){
else { me.style.display='none'; }
}
}
// Masque le conseil de stockage pour ce navigateur (simple confort : sans
// localStorage, il revient au prochain rafraîchissement, rien de plus).
function dismissHint(){
const hb=document.getElementById('app-hint');
if(!hb) return;
try{ localStorage.setItem('loki-hint-dismissed', hb.dataset.hint||''); }catch(e){}
hb.style.display='none';
}
// Journal du moteur — section repliable comme les autres. La pastille d'état
// reste un raccourci ; le chargement du journal est déclenché par l'événement
// `toggle` du <details>, qu'on l'ouvre par la pastille ou par son chevron.
+5 -2
View File
@@ -93,8 +93,11 @@ async function loadPresets(){
}
if(x.bench){
const bt=document.createElement('span'); bt.className='btag';
bt.title='prefill / decode — dernier bench de ce preset';
bt.textContent=x.bench.prefill.toFixed(0)+'-'+x.bench.decode.toFixed(0)+' t/s';
let tip='prefill / decode — dernier bench de ce preset';
if(x.bench.depth_decode) tip+='\ndecode à '+x.bench.depth+' jetons : '+x.bench.depth_decode.toFixed(1)+' t/s';
if(x.bench.kv) tip+='\ncache KV '+x.bench.kv+(x.bench.kv_quantized?' — quantifié (zone grise, pas une référence sans perte)':'');
bt.title=tip;
bt.textContent=x.bench.prefill.toFixed(0)+'-'+x.bench.decode.toFixed(0)+' t/s'+(x.bench.kv_quantized?' · KV '+x.bench.kv:'');
meta.appendChild(bt);
}
info.appendChild(nm);
+241 -44
View File
@@ -1,45 +1,100 @@
function openBenchModal(){ showModal('bench-modal'); }
function closeBenchModal(){ hideModal('bench-modal'); }
async function runBenchUI(){
const btn = document.getElementById('btn-bench');
const rerun = document.getElementById('bench-rerun');
const body = document.getElementById('bench-body');
openBenchModal();
btn.disabled = true; btn.textContent = '⏳ bench…';
rerun.disabled = true;
body.innerHTML =
'<div style="text-align:center;padding:20px 0">' +
// Le bench tourne en tâche de fond côté serveur (llm_bench_job.go) : on le lance,
// puis on suit sa progression. Fermer la fenêtre ne l'arrête pas ; la rouvrir
// (bouton bench) reprend le suivi au lieu d'en lancer un second.
let benchPoll=null;
function benchButtons(busy){
const btn=document.getElementById('btn-bench');
btn.disabled=busy; btn.textContent=busy?'⏳ bench…':'bench';
for(const id of ['bench-rerun','bench-full']){ const b=document.getElementById(id); if(b) b.disabled=busy; }
const c=document.getElementById('bench-cancel'); if(c) c.style.display=busy?'':'none';
}
function benchSpinner(text){
return '<div style="text-align:center;padding:20px 0">' +
'<div style="font-size:24px;animation:spin 1s linear infinite;display:inline-block">⏳</div>' +
'<div class="muted" style="margin-top:8px">prompt 2000 tok + 300 decode<br>~10 secondes…</div>' +
'</div>';
'<div class="muted" style="margin-top:8px">'+text+'</div></div>';
}
function benchErr(msg){ return '<div style="color:var(--err);text-align:center">erreur : '+escHtml(msg||'?')+'</div>'; }
async function runBenchUI(mode){
mode = mode==='full' ? 'full' : 'quick';
const body=document.getElementById('bench-body');
openBenchModal();
benchButtons(true);
body.innerHTML=benchSpinner('démarrage…');
try{
const r = await jget('/api/bench');
if(!r.ok){
body.innerHTML = '<div style="color:var(--err);text-align:center">erreur: '+r.error+'</div>';
const st=await jget('/api/bench/status');
if(st.running){ benchWatch(); return; }
}catch(e){}
let r;
try{ r=await jpost('/api/bench',{mode:mode}); }catch(e){ r={ok:false,error:e.message}; }
if(!r.ok){ body.innerHTML=benchErr(r.error); benchButtons(false); return; }
benchWatch();
}
async function cancelBenchUI(){ try{ await jpost('/api/bench/cancel',{}); }catch(e){} }
function benchWatch(){
clearTimeout(benchPoll);
const body=document.getElementById('bench-body');
const tick=async()=>{
let st;
try{ st=await jget('/api/bench/status'); }
catch(e){ benchPoll=setTimeout(tick,2000); return; }
if(st.running){
const step=st.steps?' ('+st.step+'/'+st.steps+')':'';
const hint=st.mode==='full'?'<br>bench complet : 1 à 5 minutes, chat et tâches en pause':'';
body.innerHTML=benchSpinner(escHtml(st.phase||'…')+step+' · '+Math.round(st.elapsed_sec||0)+' s'+hint);
benchPoll=setTimeout(tick,1000);
return;
}
const x = r.result;
body.innerHTML =
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;text-align:center">' +
'<div style="padding:14px;background:var(--panel);border:1px solid var(--border);border-radius:8px">' +
'<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.1em">Prefill</div>' +
'<div style="font-size:26px;color:var(--accent);font-weight:600;margin:6px 0">'+x.prompt_per_second.toFixed(0)+'</div>' +
'<div class="muted">tok/s</div>' +
'<div class="muted" style="font-size:11px;margin-top:8px">'+x.prompt_n+' tok · '+(x.prompt_ms/1000).toFixed(2)+'s</div>' +
'</div>' +
'<div style="padding:14px;background:var(--panel);border:1px solid var(--border);border-radius:8px">' +
'<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.1em">Decode</div>' +
'<div style="font-size:26px;color:var(--ok);font-weight:600;margin:6px 0">'+x.predicted_per_second.toFixed(1)+'</div>' +
'<div class="muted">tok/s</div>' +
'<div class="muted" style="font-size:11px;margin-top:8px">'+x.predicted_n+' tok · '+(x.predicted_ms/1000).toFixed(2)+'s</div>' +
'</div>' +
'</div>' +
'<div class="muted" style="text-align:center;font-size:11px">total '+x.elapsed_sec.toFixed(2)+'s</div>';
} finally {
btn.disabled = false; btn.textContent = 'bench';
rerun.disabled = false;
benchButtons(false);
if(st.error) body.innerHTML=benchErr(st.error);
else if(st.result) body.innerHTML=benchResultHTML(st.result, st.saved);
else body.innerHTML='';
loadPresets();
};
tick();
}
function benchDraftTxt(d){ return d&&d.n ? Math.round(d.accepted*100/d.n)+' % ('+d.accepted+'/'+d.n+')' : 'n/a'; }
function benchCard(label, color, val, unit, sub){
return '<div style="padding:14px;background:var(--panel);border:1px solid var(--border);border-radius:8px">' +
'<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.1em">'+label+'</div>' +
'<div style="font-size:26px;color:'+color+';font-weight:600;margin:6px 0">'+val+'</div>' +
'<div class="muted">'+unit+'</div>' +
'<div class="muted" style="font-size:11px;margin-top:8px">'+sub+'</div></div>';
}
function benchResultHTML(x, saved){
let h='<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;text-align:center">' +
benchCard('Prefill','var(--accent)',x.prompt_per_second.toFixed(0),'tok/s',x.prompt_n+' tok · '+(x.prompt_ms/1000).toFixed(2)+'s') +
benchCard('Decode','var(--ok)',x.predicted_per_second.toFixed(1),'tok/s',x.predicted_n+' tok · '+(x.predicted_ms/1000).toFixed(2)+'s') +
'</div>';
const rows=[];
if(x.draft) rows.push(['brouillon accepté (prose)', benchDraftTxt(x.draft)]);
const d=x.depth;
const notes=[];
if(d){
if(d.skipped) notes.push('profondeur sautée : '+d.skipped);
if(d.cold) rows.push(['prefill à froid · '+d.cold.new+' tok', d.cold.prompt_per_second.toFixed(0)+' tok/s']);
if(d.turns&&d.turns.length){
rows.push(['prefill des tours suivants (cache)', (d.cached_per_second||0).toFixed(0)+' tok/s']);
rows.push(['decode en profondeur', (d.decode_per_second||0).toFixed(1)+' tok/s']);
rows.push(['reprise du cache', d.reuse>=0 ? Math.round(d.reuse*100)+' %' : 'n/a']);
rows.push(['brouillon accepté (code)', benchDraftTxt(d.draft)]);
}
for(const n of [d.reuse_note, d.partial, d.hint]) if(n) notes.push(n);
}
if(rows.length){
h+='<table style="width:100%;font-size:12px;border-collapse:collapse">';
for(const r of rows) h+='<tr><td class="muted" style="padding:3px 0">'+escHtml(r[0])+'</td><td style="text-align:right">'+escHtml(r[1])+'</td></tr>';
h+='</table>';
}
for(const n of notes) h+='<div style="font-size:11px;color:var(--warn)">'+escHtml(n)+'</div>';
let foot='KV '+(x.kv||'?')+(x.kv_quantized?' (quantifié — zone grise)':'');
if(x.engine) foot+=' · moteur '+x.engine;
foot+=' · total '+x.elapsed_sec.toFixed(1)+'s';
if(saved===false) foot+=' · résultat partiel, non enregistré';
h+='<div class="muted" style="text-align:center;font-size:11px">'+escHtml(foot)+'</div>';
if(d&&d.cold) h+='<div class="muted" style="text-align:center;font-size:11px">Le slot est effacé après le bench quand le moteur le permet : sinon, le prochain message reprend la conversation depuis le cache RAM (CACHE_RAM) ou la recalcule.</div>';
return h;
}
async function switchTo(n,name,id){
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
@@ -165,7 +220,7 @@ async function openItem(kind, key){
document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings();
attachDownload(); // téléchargement encore en cours côté serveur ?
await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(),
await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(), populateDraft(),
populateDlDirs(), refreshInstalled()]);
if(seq !== openSeq) return;
}
@@ -523,7 +578,7 @@ async function deleteInstalled(m){
}
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('modèle supprimé — ' + fmtSize(r.freed||0) + ' libérés');
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(),
await Promise.all([populateModelPicker(), populateMmproj(), populateDraft(), populateDlDirs(),
populateModelDirs(), refreshInstalled()]);
}
// Un .gguf donné est-il déjà sur le disque, complet ? Sert avant de proposer un
@@ -856,7 +911,7 @@ function cfgWriteKey(key, val){
// jeton par jeton pour les interrupteurs (mlock, flash-attn, n-cpu-moe…), sans
// toucher aux autres drapeaux (--jinja, --device…) qui restent en config brute.
function eaTokens(){ return cfgReadKey('EXTRA_ARGS').split(/\s+/).filter(Boolean); }
function eaSetTokens(t){ cfgWriteKey('EXTRA_ARGS', t.join(' ')); }
function eaSetTokens(t){ cfgWriteKey('EXTRA_ARGS', t.join(' ')); syncKVSub(); }
function eaHasFlag(flag){ return eaTokens().includes(flag); }
function eaToggleFlag(flag, on){
const t = eaTokens().filter(x=>x!==flag);
@@ -882,8 +937,11 @@ function populateSettings(){
set('s-threads', cfgReadKey('THREADS'));
set('s-batch', cfgReadKey('BATCH'));
set('s-ubatch', cfgReadKey('UBATCH'));
set('s-cram', cfgReadKey('CACHE_RAM'));
refreshCacheRAM();
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
set('s-kv', cfgReadKey('KV_TYPE'));
syncKVSub();
// Échantillonnage : envoyé PAR REQUÊTE (applySampling côté serveur), donc pas
// besoin de redémarrer le moteur pour qu'un changement prenne effet.
set('s-temp', cfgReadKey('TEMP'));
@@ -894,7 +952,7 @@ function populateSettings(){
set('s-reppen', cfgReadKey('REPEAT_PENALTY'));
set('s-moe', eaGetValued('--n-cpu-moe'));
set('s-spec-n', eaGetValued('--spec-draft-n-max'));
setSpecType(eaGetValued('--spec-type'));
setSpecType(eaGetValued('--spec-type') || specFromKey(cfgReadKey('SPEC')));
const chk = (id,v)=>{ const e=document.getElementById(id); if(e) e.checked=v; };
// Raisonnement : trois états dans le fichier, deux positions sur l'interrupteur.
// « off » est une interdiction explicite passée au moteur ; la clé ABSENTE, elle,
@@ -931,6 +989,80 @@ function populateSettings(){
set('s-loadmode', lm);
syncLoadModeSub(lm);
}
// Cache KV : le sous-titre dit ce que le moteur utilisera VRAIMENT, comme
// effectiveKVTypes côté Go. EXTRA_ARGS ferme la ligne de commande et la dernière
// occurrence gagne : un --cache-type-k q8_0 écrit à la main l'emporte sur la
// liste, qui afficherait sinon « f16 » pendant que le moteur tourne en q8_0.
// Les drapeaux de cache qui changent ce que voit le modèle (--context-shift,
// --cache-reuse > 0) sont signalés au même endroit. Rien n'est jamais modifié
// ici : c'est un affichage.
const KV_FIDELITY = {
'bf16': 'numérique différente de f16',
'q8_0': 'modifie légèrement les sorties', 'q5_0': 'modifie légèrement les sorties', 'q5_1': 'modifie légèrement les sorties',
'q4_0': 'perte mesurable', 'q4_1': 'perte mesurable', 'iq4_nl': 'perte mesurable',
};
const KV_RANK = {'':0, 'f16':0, 'f32':0, 'bf16':1, 'q4_0':3, 'q4_1':3, 'iq4_nl':3};
function eaLastValue(flags){
let v = '';
const t = eaTokens();
t.forEach((a, i) => {
const eq = a.indexOf('='), name = eq > 0 ? a.slice(0, eq) : a;
if(!flags.includes(name)) return;
if(eq > 0) v = a.slice(eq+1);
else if(i+1 < t.length) v = t[i+1];
});
return v;
}
// Valeur auto du cache de prompts, calculée côté serveur comme au lancement
// (VRAM, RAM libre, métadonnées du modèle) : affichée en indication du champ
// CACHE_RAM. Une seule demande en vol ; la dernière gagne.
let cramSeq = 0;
async function refreshCacheRAM(){
const el = document.getElementById('s-cram'), sub = document.getElementById('s-cram-sub');
if(!el) return;
const seq = ++cramSeq;
let r = {};
try{ r = await jpost('/api/preset/cacheram', {content: document.getElementById('m-content').value}); }
catch(_){ r = {}; }
if(seq !== cramSeq || !r.ok) return;
// Valeur fixée (CACHE_RAM, -cram, LLAMA_ARG_CACHE_RAM) : -1 et 0 ne sont pas
// « le défaut du moteur », la raison seule suffit.
const fixed = /^fixé/.test(r.why || '');
el.placeholder = fixed ? (r.mib < 0 ? 'sans limite' : r.mib === 0 ? 'coupé' : String(r.mib))
: 'auto · ' + (r.mib > 0 ? r.mib : r.default);
if(sub) sub.textContent = 'RAM hôte, copie exacte du contexte · jamais la VRAM' +
(r.why ? ' · ' + r.why : '') + (r.mib > 0 || fixed ? '' : ' · défaut du moteur');
}
function syncKVSub(){
const el = document.getElementById('s-kv-sub');
if(!el) return;
const kv = cfgReadKey('KV_TYPE');
const xk = eaLastValue(['-ctk','--cache-type-k']), xv = eaLastValue(['-ctv','--cache-type-v']);
const k = (xk || cfgReadKey('KV_TYPE_K') || kv).toLowerCase();
const v = (xv || cfgReadKey('KV_TYPE_V') || kv).toLowerCase();
const rank = x => (x in KV_RANK) ? KV_RANK[x] : 2;
const worst = rank(v) > rank(k) ? v : k;
const parts = [];
if(rank(worst) === 0){
parts.push(xk || xv ? 'f16 (défini par EXTRA_ARGS)' : 'compression du contexte');
} else {
const label = KV_FIDELITY[worst] || 'modifie les sorties';
parts.push((xk || xv ? 'défini par EXTRA_ARGS : ' : '') + (k||'f16')+'/'+(v||'f16')+' — '+label);
// Placement figé à la main : --fit ne tourne pas et n'absorbera pas le
// surplus d'un retour en f16. Comme tensorOverride côté Go : un
// --n-cpu-moe 0 ne place rien.
const placed = f => { const n = eaLastValue(f).trim(); return n !== '' && n !== '0'; };
if(eaTokens().some(a => ['-ot','--override-tensor','--cpu-moe','-cmoe'].includes(a.split('=')[0])) ||
placed(['--n-cpu-moe','-ncmoe']) || placed(['--n-cpu-ffn','-ncffn']))
parts.push('repasser en f16 : plus de VRAM, --n-cpu-moe à relever');
}
let shift = false;
eaTokens().forEach(a => { if(a === '--context-shift') shift = true; else if(a === '--no-context-shift') shift = false; });
if(shift) parts.push('--context-shift : jette des jetons quand le contexte est plein');
const reuse = parseInt(eaLastValue(['--cache-reuse']), 10);
if(reuse > 0) parts.push('--cache-reuse : cache réutilisé de façon approchée');
el.textContent = parts.join(' · ');
}
// Chargement du modèle. llama.cpp récent a REMPLACÉ --mlock / --no-mmap par
// --load-mode (auto, none, mmap, mlock, mmap+mlock, dio). L'éditeur écrit la
// forme moderne ; au lancement, loki serve la retraduit pour un moteur ancien
@@ -969,7 +1101,14 @@ function syncLoadModeSub(mode){
const el = document.getElementById('s-loadmode-sub');
if(el) el.textContent = LOAD_MODE_SUB[mode] ?? mode;
}
// --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ----------------
// --- Décodage spéculatif (SPEC, MODEL_DRAFT, --spec-type, --spec-draft-n-max)
// « auto » et « MTP » passent par la clé SPEC : Loki cherche la tête MTP dans
// le fichier et pose les garde-fous au lancement. Les autres types restent des
// --spec-type d'EXTRA_ARGS, qui ont toujours le dernier mot côté serveur.
function specFromKey(v){
v = String(v||'').trim().toLowerCase();
return v === 'auto' ? 'auto' : v === 'mtp' ? 'draft-mtp' : '';
}
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
// par des virgules ; une valeur composée (ou un type sorti après cette version
// de loki) ne correspondrait à aucune option et serait silencieusement effacée
@@ -995,17 +1134,58 @@ function syncSpecRow(){
if(!sel || !row) return;
const on = !!sel.value;
row.style.display = on ? '' : 'none';
// Le brouillon ne sert qu'à SPEC (auto / MTP) ; un brouillon déjà posé reste
// visible pour qu'on puisse le retirer.
const dr = document.getElementById('s-draft-row');
if(dr) dr.style.display = (sel.value === 'auto' || sel.value === 'draft-mtp' || cfgReadKey('MODEL_DRAFT')) ? '' : 'none';
return on;
}
function onSpecType(){
const v = document.getElementById('s-spec').value;
eaSetValued('--spec-type', v);
const viaKey = v === 'auto' || v === 'draft-mtp';
// « non » écrit SPEC=off : explicite, et identique au défaut du serveur.
cfgWriteKey('SPEC', v === 'auto' ? 'auto' : v === 'draft-mtp' ? 'mtp' : v ? '' : 'off');
eaSetValued('--spec-type', viaKey ? '' : v);
if(!v){
eaSetValued('--spec-draft-n-max', '');
document.getElementById('s-spec-n').value = '';
}
syncSpecRow();
}
// Liste des brouillons : tous les .gguf sauf les projecteurs vision. Une tête
// MTP publiée à part (mtp-*.gguf) est ce qu'on y choisira le plus souvent.
async function populateDraft(){
const sel = document.getElementById('m-draft');
if(!sel) return;
const list = await jget('/api/models');
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
const items = (list||[]).filter(m => !isMmprojName(m.name));
const hit = matchModel(cur, items);
let html = '<option value="">— aucun —</option>';
let matched = false;
for(const m of items){
const on = (m === hit) ? ' selected' : '';
if(on) matched = true;
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
}
if(cur && !matched){
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
}
sel.innerHTML = html;
syncSpecRow();
}
// Choisir un brouillon, c'est demander la spéculation : on passe en « auto » si
// elle était coupée (les garde-fous de Loki restent en place).
function onPickDraft(){
const v = document.getElementById('m-draft').value;
cfgWriteKey('MODEL_DRAFT', v);
const sel = document.getElementById('s-spec');
if(v && sel && !sel.value){ sel.value = 'auto'; onSpecType(); toast('décodage spéculatif : auto'); }
// Un ancien preset portait le MTP en --spec-type dans EXTRA_ARGS : sans clé
// SPEC, le brouillon serait ignoré au lancement. On le range dans SPEC=mtp.
else if(v && sel && sel.value === 'draft-mtp' && specFromKey(cfgReadKey('SPEC')) !== 'draft-mtp') onSpecType();
syncSpecRow();
}
// Replie/déplie l'éditeur du fichier .env (config brute) dans le modal preset.
function toggleRaw(){
@@ -1137,7 +1317,7 @@ let dlQueue = [];
// fichier. Le fichier a pu atterrir hors du dossier loki : l'option porte alors
// le chemin complet, pas le simple nom.
async function selectInstalled(fname){
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(), refreshInstalled()]);
await Promise.all([populateModelPicker(), populateMmproj(), populateDraft(), populateDlDirs(), refreshInstalled()]);
const pick = (id, cb)=>{
const s = document.getElementById(id);
if(!s) return false;
@@ -1145,6 +1325,7 @@ async function selectInstalled(fname){
if(!o) return false;
s.value = o.value; cb(); return true;
};
if(/^mtp-/i.test(fname)) return pick('m-draft', onPickDraft);
return isMmprojName(fname) ? pick('m-mmproj', onPickMmproj) : pick('m-model', onPickModel);
}
async function startDownload(){
@@ -1437,11 +1618,27 @@ async function hfPickRepo(repo){
}
o.appendChild(note);
// Tête MTP publiée à part (mtp-*.gguf) : même règle que le projecteur, elle
// ne vaut que pour SON modèle. Décochée : la spéculation reste un choix.
let dfChk = null;
const df = (r.drafts||[])[0] && hfBestProjector(r.drafts);
if(df){
const dn = document.createElement('label');
dn.className = 'hf-mm';
dfChk = document.createElement('input');
dfChk.type = 'checkbox';
const t = document.createElement('span');
t.textContent = ' installer aussi la tête MTP (décodage plus rapide, ~1-2 Go de VRAM) — '+df.name+' ('+fmtSize(df.size)+')'
+ (installed(df.name) ? ' — déjà sur le disque' : '');
dn.append(dfChk, t);
o.appendChild(dn);
}
const list = document.createElement('div'); list.className = 'hf-list';
for(const m of r.models){
const row = document.createElement('div');
row.className = 'hf-row';
row.onclick = ()=>hfInstall(m, mm && mmChk && mmChk.checked ? mm : null);
row.onclick = ()=>hfInstall(m, mm && mmChk && mmChk.checked ? mm : null, df && dfChk && dfChk.checked ? df : null);
const n = document.createElement('span'); n.className = 'hf-name';
n.textContent = m.quant || m.name;
const meta = document.createElement('span'); meta.className = 'hf-meta';
@@ -1488,14 +1685,14 @@ function hfBestProjector(list){
// Installe : le modèle d'abord, le projecteur ensuite (la file s'en charge). Le
// champ Vision du preset ne se remplit correctement que dans cet ordre.
async function hfInstall(model, projector){
async function hfInstall(model, projector, draft){
// Le fichier est là : rien à télécharger, donc rien à confirmer — la sonde le
// constate côté serveur et startDownloadURL le sélectionne.
const here = await modelIsOnDisk(model.name);
if(!here && model.verdict === 'trop' && !await askConfirm(
(model.why||'Ce modèle dépasse la mémoire disponible.')+'\n\nLe téléchargement fonctionnera, mais le moteur risque de ne pas le charger.',
{title:'Installer quand même ?', okText:'Installer', danger:true})) return;
dlQueue = projector ? [projector.url] : [];
dlQueue = [projector, draft].filter(Boolean).map(x => x.url);
document.getElementById('m-hf-url').value = model.url;
await startDownloadURL(model.url);
}
+8 -1
View File
@@ -317,7 +317,9 @@ function renderToolMsg(el, tu){
let add=0, del=0;
if(tu.added!=null || tu.removed!=null){ add=tu.added||0; del=tu.removed||0; }
else if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); }
else if(tu.body){ add=bodyLineCount(tu.body); }
// Pendant la frappe, body n'est que la FIN du corps : body_lines porte le
// vrai total (repli sur le décompte pour un serveur d'avant ce champ).
else if(tu.body){ add=tu.body_lines!=null ? tu.body_lines : bodyLineCount(tu.body); }
if(add||del) setLabelCounts(el, add, del);
const body=bodyOf(el); body.innerHTML='';
const head=document.createElement('div'); head.className='tool-head';
@@ -340,6 +342,11 @@ function renderToolMsg(el, tu){
sub.textContent='écriture en cours'; // le +N vit sur l'étiquette (visible repliée)
body.appendChild(sub);
const pre=document.createElement('pre'); pre.className='diff live';
// Début omis par le serveur (seules les dernières lignes voyagent) : on le dit.
if(tu.body_tail){
const more=document.createElement('span'); more.className='dl'; more.textContent=' …';
pre.appendChild(more);
}
lines.forEach((t,i)=>{
const ln=document.createElement('span');
ln.className='dl add'+(i===lines.length-1?' fresh':'');
+40 -8
View File
@@ -237,16 +237,35 @@ function renderStats(el, s){
if(!el||!s) return;
const parts=[];
const pt=s.prompt_tokens||s.prompt_tokens_total;
// Débit de lecture inconnu (API tierce sans timings) : on n'affiche que la taille.
if(pt) parts.push('prefill '+pt+' tok'+(s.prompt_per_second ? ' · '+s.prompt_per_second.toFixed(0)+' tok/s' : ''));
const pps=s.prompt_per_second ? ' · '+s.prompt_per_second.toFixed(0)+' tok/s' : '';
// Cache connu (cache_tokens présent, même à 0) : ce qui a été recalculé et ce
// qui venait du cache. Inconnu (moteur ancien, API tierce) : la taille seule,
// comme avant — un « 0 en cache » inventé serait pire que rien.
const cached=s.cache_tokens;
if(cached!=null && (s.prompt_tokens!=null || s.prompt_tokens_total)){
const fresh=s.prompt_tokens!=null ? s.prompt_tokens : Math.max(0,s.prompt_tokens_total-cached);
parts.push('prefill '+nfmt(fresh)+' nouveaux / '+nfmt(cached)+' en cache'+pps);
} else if(pt) parts.push('prefill '+pt+' tok'+pps); // débit inconnu (API tierce sans timings) : la taille seule
if(s.gen_tokens) parts.push('decode '+s.gen_tokens+' tok · '+(s.gen_per_second||0).toFixed(1)+' tok/s');
// Perte de cache signalée par le serveur (seuil relevé sur un modèle hybride,
// dont les points de reprise font perdre un peu à chaque étape).
if(s.lost_alert && s.lost) parts.push(nfmt(s.lost)+' recalculés'+(s.lost_after ? ' après '+s.lost_after.split(',').map(k=>PERF_KIND[k]||k).join(', ') : ''));
if(!parts.length) return;
// Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette
// est masquée dans cette mise en page). Bulle repliable : l'étiquette EST le
// bouton de repli, on y écrit comme avant.
if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(workLabel()||[], parts).join(' · '));
else paintStats(el, parts.join(' · '));
else{
paintStats(el, parts.join(' · '));
const sl=el.querySelector(':scope > .statline');
if(sl) sl.classList.toggle('cache-miss', !!s.lost_alert);
}
}
// Ce qui s'est intercalé avant une perte de cache (lost_after, perf_log.go),
// dit en clair : les identifiants internes n'ont rien à faire dans l'interface.
const PERF_KIND={main:'tour', subagent:'sous-agent', verify:'vérification', task:'tâche', compact:'compaction', bench:'bench', foreign:'client /v1'};
// Milliers séparés par une espace fine insécable : « 41 230 », pas « 41230 ».
function nfmt(n){ return String(Math.round(n||0)).replace(/\B(?=(\d{3})+(?!\d))/g,'\u202f'); }
// --- Compteurs de bulle -----------------------------------------------------
// Ils sont PAR BULLE, jamais par tour. Un tour d'agent en ouvre une nouvelle
// après CHAQUE appel d'outil (le flux repasse T.contentEl à null) ; les
@@ -537,11 +556,16 @@ function handleDelta(d){
if(d.compact_noop){ setCompacting(false); if(!REPLAYING) toast('rien à compacter (contexte déjà minimal)'); return; }
if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; }
if(d.stats){ T.serverStats=d.stats; feedConvSpeed(d.stats);
// Conseil ponctuel (cache de prompts trop petit) : en direct seulement.
if(d.stats.cache_hint && !REPLAYING) toast(d.stats.cache_hint);
// prompt_tokens_total = comptage exact du moteur (include_usage). Absent sur
// certains llama-server récents : le serveur publie alors un `ctx_used`
// estimé en fin de tour, traité plus bas — on ne remet donc PAS la jauge à
// zéro ici, on la laisse simplement inchangée.
if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); }
// zéro ici, on la laisse simplement inchangée. Même calcul que le serveur
// (StatsEvent.ctxAfter) : le raisonnement, jamais renvoyé au modèle, ne
// compte pas. ctx_isolated = passe du vérificateur, sur sa propre trace :
// ses chiffres ne disent rien de la discussion, la jauge ne bouge pas.
if(d.stats.prompt_tokens_total && !d.ctx_isolated){ const g=d.stats.gen_tokens||0; setCtxUsed(d.stats.prompt_tokens_total+g-Math.min(g, d.stats.reasoning_tokens||0)); }
if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }
if(d.tool_used){
settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé
@@ -746,6 +770,14 @@ async function send(){
fail('échec de l\'envoi — réessaie');
}
loadAll();
setInterval(loadStatus, 5000);
setInterval(loadVram, 3000);
setInterval(loadRam, 3000);
// Jauges et pastille d'état : rien à rafraîchir dans un onglet que personne ne
// regarde. Chaque tic lançait sinon un nvidia-smi côté serveur, pour chaque
// onglet ou téléphone laissé ouvert, pendant que le modèle génère. Au retour de
// l'onglet, une lecture immédiate remet tout à jour.
const pollVisible=(fn)=>()=>{ if(!document.hidden) fn(); };
setInterval(pollVisible(loadStatus), 5000);
setInterval(pollVisible(loadVram), 3000);
setInterval(pollVisible(loadRam), 3000);
document.addEventListener('visibilitychange', ()=>{
if(!document.hidden){ loadStatus(); loadVram(); loadRam(); }
});
+14
View File
@@ -140,6 +140,20 @@ async function loadEngine(){
// Le retour arrière n'a de sens que si on n'est pas déjà dessus.
const revert = document.getElementById('eng-revert');
if(revert) revert.style.display = (s.image_bin && s.source !== 'image') ? '' : 'none';
// Versions téléchargées gardées à côté (la précédente survit à une mise à
// jour) : revenir dessus ne demande ni réseau ni que le tag soit encore publié.
const others = document.getElementById('eng-others');
if(others) others.innerHTML = (s.installed || []).filter(v=>!v.in_use).map(v=>{
const tag = String(v.tag).replace(/[^A-Za-z0-9._-]/g,'');
return '<button onclick="engUse(\''+tag+'\')">utiliser '+(v.build ? 'b'+v.build : tag)+'</button>';
}).join(' ');
// Build trop ancien pour les points de reprise des hybrides (voir
// engineBuildNotice) : un avis, rien n'est changé d'office ici.
const notice = document.getElementById('eng-notice');
if(notice){
notice.textContent = s.notice ? '⚠ '+s.notice : '';
notice.style.display = s.notice ? '' : 'none';
}
engSay('');
}
+3
View File
@@ -1116,6 +1116,9 @@ h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
#chat .msg .statline{margin-top:10px;font-family:var(--mono);font-size:10.5px;letter-spacing:.02em;
color:var(--dim);opacity:.75}
#chat .msg .statline:empty{display:none}
/* Cache de prompts perdu (gros recalcul) : la vitesse passe en ambre, la
ligne reste discrète mais ne se lit plus comme un tour ordinaire. */
#chat .msg .statline.cache-miss>.spd{color:var(--warn)}
/* « Masquer la vitesse de génération » ne masque QUE la vitesse : le temps de
travail du tour (.worktime) n'est pas une mesure de moteur, c'est ce que la
réponse a coûté. Une ligne qui ne portait que de la vitesse disparaît en
+43 -37
View File
@@ -83,6 +83,9 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
// id du preset actif : un autre appareil a pu basculer, l'UI se
// resynchronise sans reload (voir loadStatus). AJEAN 0.13.6.
"preset": activePresetID(),
// Conseil de performance (stockage sur fuse.shfs d'Unraid) : encart
// discret, distinct de « warn » qui annonce une perte de données.
"hint": storageFuseHint(),
})
}
@@ -112,6 +115,11 @@ func modelLoadError() string {
switch {
case strings.Contains(low, "model loaded"), strings.Contains(low, "server is listening"):
loaded = true
case strings.Contains(low, "failed to create mtp context"),
strings.Contains(low, "failed to load draft model"),
strings.Contains(low, "failed to initialize speculative decoding context"):
// Avant l'échec générique : c'est le brouillon qui manque, pas le modèle.
reason = "le décodage spéculatif n'a pas pu démarrer (tête MTP ou modèle brouillon) — SPEC=off dans le preset"
case strings.Contains(l, "has offset") && strings.Contains(l, "expected"):
reason = "format de quantification non reconnu par ce moteur"
case strings.Contains(low, "unknown model architecture"),
@@ -184,10 +192,11 @@ func handleServiceLog(w http.ResponseWriter, r *http.Request) {
// handleVram expose l'état des cartes à l'interface (jauges du moniteur).
// La lecture nvidia-smi elle-même vit dans web_vram.go, avec le déchargement
// de la VRAM qui s'en sert pour chiffrer la mémoire rendue.
// de la VRAM qui s'en sert pour chiffrer la mémoire rendue. Lecture partagée
// (gpuStatsCached) : tous les onglets se contentent d'un seul nvidia-smi.
func handleVram(w http.ResponseWriter, r *http.Request) {
gpus := []map[string]any{}
for _, g := range gpuStats() {
for _, g := range gpuStatsCached() {
gpus = append(gpus, map[string]any{
"name": g.Name, "used": g.Used, "total": g.Total, "util": g.Util, "temp": g.Temp,
})
@@ -399,6 +408,7 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
return
}
store := loadBenchStore()
cur := ReadConfig()
out := []map[string]any{}
for _, p := range list {
item := map[string]any{"id": p.ID, "name": p.Name, "active": p.Active}
@@ -421,12 +431,23 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
item["reasoning"] = strings.ToLower(r)
}
}
if sb, ok := store[p.ID]; ok && benchMatchesPreset(sb, parseEnv(content)) {
item["bench"] = map[string]any{
if sb, ok := store[p.ID]; ok && benchMatchesPreset(sb, parseEnv(content), cur) {
bench := map[string]any{
"prefill": sb.Result.PromptPerSecond,
"decode": sb.Result.PredictedPerSec,
"at": sb.At,
}
// Types de cache à côté de la mesure : un KV quantifié n'est pas une
// référence sans perte, la pastille le dit.
if sb.Result.KV != "" {
bench["kv"] = sb.Result.KV
bench["kv_quantized"] = sb.Result.KVQuantized
}
if d := sb.Result.Depth; d != nil && d.DecodePerSec > 0 && d.Cold != nil {
bench["depth"] = d.Cold.New
bench["depth_decode"] = d.DecodePerSec
}
item["bench"] = bench
}
out = append(out, item)
}
@@ -463,6 +484,20 @@ type saveReq struct {
Content string `json:"content"`
}
// handlePresetCacheRAM : aperçu de la taille du cache de prompts en RAM pour le
// preset en cours d'édition (champ CACHE_RAM de l'éditeur).
func handlePresetCacheRAM(w http.ResponseWriter, r *http.Request) {
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
mib, why := cacheRAMPreview(req.Content)
sendJSON(w, 200, map[string]any{"ok": true, "mib": mib, "default": engineCacheRAMDefault, "why": why})
}
func handlePresetSave(w http.ResponseWriter, r *http.Request) {
var req presetSaveReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -772,6 +807,10 @@ func handleMemUnlock(w http.ResponseWriter, r *http.Request) {
// handleMemLock purge la DEK de la RAM (reverrouille sans redémarrer).
func handleMemLock(w http.ResponseWriter, r *http.Request) {
// Écritures différées de la discussion (chat_persist.go) d'abord : clé
// purgée, elles seraient sautées et le message tout juste envoyé ne
// toucherait le disque qu'au prochain déverrouillage.
flushPersister(persistExitWait)
clearMemDEK()
sendJSON(w, 200, map[string]any{"ok": true, "health": memHealth()})
}
@@ -1313,39 +1352,6 @@ func svcHandler(action string) http.HandlerFunc {
// handleChat is the SSE proxy with tool-calling. The HTTP handler writes raw
// data: lines matching what the embedded JS expects (delta.content,
// delta.reasoning_content, delta.tool_used).
// handleBench runs `runBench` synchronously. Long enough (~30-60s) that we
// rely on the client side to show a spinner / disable the button.
func handleBench(w http.ResponseWriter, r *http.Request) {
nPrompt, nPredict := 2000, 300
if v := r.URL.Query().Get("prompt"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
nPrompt = parsed
}
}
if v := r.URL.Query().Get("n"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
nPredict = parsed
}
}
res, err := runBench(nPrompt, nPredict)
if err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "result": res})
}
// handleBenchLast returns the most recent persisted benchmark, or {ok:false}
// when none has been run yet.
func handleBenchLast(w http.ResponseWriter, r *http.Request) {
sb := loadLastBench()
if sb == nil {
sendJSON(w, 200, map[string]any{"ok": false})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "result": sb.Result, "model": sb.Model, "at": sb.At})
}
// chatReq est le corps d'une requête de chat (commun au chat clair et au chat E2E).
//
// `messages` et `ctx_used`, que les clients envoyaient du temps où l'historique
+69 -13
View File
@@ -83,12 +83,76 @@ func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, fun
// se ferme. La GÉNÉRATION est lancée séparément par /api/chat/send dans une
// goroutine détachée — fermer le navigateur n'arrête donc plus rien. Partagé par
// handleChat (clair) et handleE2EChat (chiffré).
func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) bool) {
func runChatStream(ctx context.Context, body chatReq, out *sseStream) {
tail := -1
if body.Tail != nil {
tail = *body.Tail
}
conv.SubscribeTail(ctx, body.From, tail, body.ConvID, emit)
conv.subscribeSink(ctx, body.From, tail, body.ConvID, tailSink{emit: out.emit, queue: out.queue, flush: out.flush})
}
// sseBatchBytes / sseBatchEvents : taille d'un envoi groupé. Un client très en
// retard peut trouver des milliers d'événements en attente : on écrit par
// tranches au lieu de tout empiler en mémoire.
const (
sseBatchBytes = 128 << 10
sseBatchEvents = 256
)
// sseStream : écriture des trames `data:` d'un flux d'abonnement. frame met un
// événement en forme (en clair, ou scellé pour le relais E2E — un sceau par
// événement, le client déchiffre chaque ligne `data:` séparément). mu est
// celui du battement de cœur : jamais deux écritures mêlées sur w.
type sseStream struct {
w http.ResponseWriter
flusher http.Flusher
mu *sync.Mutex
frame func(map[string]any) ([]byte, bool)
buf []byte
n int
}
// emit : un événement, écrit et poussé tout de suite (après ce qui attend).
func (s *sseStream) emit(obj map[string]any) bool {
return s.queue(obj) && s.flush()
}
// queue : un événement de plus dans le tampon, écrit d'office s'il déborde.
func (s *sseStream) queue(obj map[string]any) bool {
b, ok := s.frame(obj)
if !ok {
return false
}
s.buf = append(s.buf, b...)
s.n++
if len(s.buf) >= sseBatchBytes || s.n >= sseBatchEvents {
return s.flush()
}
return true
}
// flush : une seule écriture et un seul flush pour tout ce qui attend.
func (s *sseStream) flush() bool {
if len(s.buf) == 0 {
return true
}
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.w.Write(s.buf)
// Un gros événement (diff final) ne doit pas garder son tampon à vie.
if cap(s.buf) > 4*sseBatchBytes {
s.buf = nil
} else {
s.buf = s.buf[:0]
}
s.n = 0
if err != nil {
return false
}
if s.flusher != nil {
s.flusher.Flush()
}
return true
}
// handleChatSend ajoute un message et lance la génération en arrière-plan. Réponse
@@ -244,17 +308,9 @@ func handleChat(w http.ResponseWriter, r *http.Request) {
flusher, _ := w.(http.Flusher)
mu, stop := sseHeartbeat(w, flusher)
defer stop()
emit := func(obj map[string]any) bool {
frame := func(obj map[string]any) ([]byte, bool) {
b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": obj}}})
mu.Lock()
defer mu.Unlock()
if _, err := w.Write([]byte("data: " + string(b) + "\n\n")); err != nil {
return false
}
if flusher != nil {
flusher.Flush()
}
return true
return []byte("data: " + string(b) + "\n\n"), true
}
runChatStream(r.Context(), body, emit)
runChatStream(r.Context(), body, &sseStream{w: w, flusher: flusher, mu: mu, frame: frame})
}
+21 -13
View File
@@ -33,16 +33,7 @@ func handleEngineStatus(w http.ResponseWriter, r *http.Request) {
bin := currentEngineBin()
build, commit := engineBuildOf(bin)
imageBin := providedEngineBin()
source := "custom"
switch {
case imageBin != "" && samePath(bin, imageBin):
source = "image"
case engineOwns(bin):
source = "downloaded"
case prebuiltOwns(bin):
source = "prebuilt"
}
source := engineSource(bin)
var installed []map[string]any
for _, tag := range engineInstalled() {
@@ -67,6 +58,9 @@ func handleEngineStatus(w http.ResponseWriter, r *http.Request) {
"dir": engineDir(),
"installed": installed,
"job": lcJobSnapshot(0, false),
// Avis « moteur trop ancien » : seulement pour un build digne de foi
// (voir engineBuildTrust), jamais pour un compilé ou un fork.
"notice": engineBuildNotice(engineBuildTrust(source, build)),
})
}
@@ -166,12 +160,26 @@ func engineRunUpdate(tag string) {
lcFail(err)
return
}
// On garde la version qu'on vient de poser ; les précédentes ne servent plus
// (le retour arrière se fait sur le moteur de l'image, qui, lui, est intact).
enginePrune(map[string]bool{filepath.Base(filepath.Dir(bin)): true}, lcAppend)
enginePrune(engineKeepAfterUpdate(bin, ancien, lcAppend), lcAppend)
lcDone("moteur mis à jour (" + tag + ")")
}
// engineKeepAfterUpdate : les versions téléchargées que le ménage épargne après
// une bascule. La nouvelle, et celle qui tournait juste avant si c'en était une
// : le moteur de l'image n'est pas forcément l'ancien build (il dépend de
// LLAMACPP_IMAGE), et revenir sur la version d'hier ne doit demander ni réseau
// ni que le tag soit encore publié. Les plus anciennes partent (~450 Mo chacune).
func engineKeepAfterUpdate(bin, ancien string, logf func(string)) map[string]bool {
keep := map[string]bool{filepath.Base(filepath.Dir(bin)): true}
if prev := engineTagOf(ancien); prev != "" && !keep[prev] {
keep[prev] = true
if logf != nil {
logf("version précédente gardée pour revenir en arrière : " + prev)
}
}
return keep
}
// engineSwitchTo pointe BIN sur un moteur et redémarre le service s'il tournait.
func engineSwitchTo(bin string) error {
wasUp := serviceIsActive()
+13 -1
View File
@@ -52,6 +52,11 @@ func cmdWeb(args []string) error {
if dv := dataVolumeWarning(); dv != "" {
fmt.Println(red(dv))
}
// Simple conseil de performance (fuse.shfs d'Unraid) : jaune, pas rouge —
// rien n'est perdu, seul le décodage d'un gros modèle hors RAM ralentit.
if sh := storageFuseHint(); sh != "" {
fmt.Println(yellow(sh))
}
if readWebKey() == "" {
fmt.Printf("%s API de pilotage NON protégée (aucune clé). Avant de l'exposer sur internet :\n", yellow("[!]"))
fmt.Printf(" %s\n", bold("loki set-web-key"))
@@ -131,6 +136,9 @@ func newWebMux() *http.ServeMux {
// Planificateur des tâches : une goroutine, un tic par minute, dans le process
// qui détient la conversation et le modèle (idempotent, cf. sync.Once).
StartTaskScheduler()
// Jeton de tentative du décodage spéculatif automatique : effacé dès que le
// moteur répond, sans dépendre d'une page ouverte (voir backend_serve_spec.go).
startSpecAttemptWatch()
mux := http.NewServeMux()
// Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la
// donnée et toutes les actions passent par /api/* qui, lui, exige la clé.
@@ -245,6 +253,7 @@ func newWebMux() *http.ServeMux {
api("/api/presets", handlePresets)
api("/api/preset", handlePreset)
api("/api/preset/save", handlePresetSave)
api("/api/preset/cacheram", handlePresetCacheRAM) // aperçu de --cache-ram pour le preset édité
api("/api/preset/delete", handlePresetDelete)
// Presets externes : le chat part vers une API OpenAI-compatible distante au
// lieu du llama-server local (backend_external.go).
@@ -321,8 +330,11 @@ func newWebMux() *http.ServeMux {
api("/api/start", svcHandler("start"))
api("/api/stop", svcHandler("stop"))
api("/api/restart", svcHandler("restart"))
api("/api/bench", handleBench)
api("/api/bench", handleBench) // POST : lance le benchmark en arrière-plan (llm_bench_job.go)
api("/api/bench/status", handleBenchStatus) // progression, puis résultat
api("/api/bench/cancel", handleBenchCancel) // POST : annule
api("/api/bench/last", handleBenchLast)
api("/api/perf/summary", handlePerfSummary)
api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant)
+102 -10
View File
@@ -13,12 +13,15 @@
package loki
import (
"context"
"encoding/json"
"errors"
"net/http"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
@@ -32,15 +35,40 @@ type gpuStat struct {
Temp int
}
// nvidiaSmiQuery lance la requête nvidia-smi. Variable pour que les tests
// substituent un faux exécutable et comptent les lancements.
//
// Bornée dans le temps, comme nvidiaGPUCount : un pilote coincé peut faire
// pendre nvidia-smi, et gpuStatsCached tient son verrou pendant la requête. Sans
// borne, un seul nvidia-smi pendu figeait les jauges de TOUS les onglets
// jusqu'au redémarrage de Loki, même une fois le pilote revenu. Dix secondes :
// large pour une première lecture lente (pilote sans mode persistant, plusieurs
// cartes), une erreur ordinaire au-delà — retentée au délai normal.
var nvidiaSmiQuery = func() ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), nvidiaSmiTimeout)
defer cancel()
cmd := hideCmd(exec.CommandContext(ctx, "nvidia-smi",
"--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu",
"--format=csv,noheader,nounits"))
cmd.WaitDelay = time.Second // un descendant qui garderait la sortie ouverte ne retient pas Wait
return cmd.Output()
}
const nvidiaSmiTimeout = 10 * time.Second
// gpuStats interroge nvidia-smi. Renvoie nil quand il est absent (machine sans
// GPU NVIDIA, Mac, CPU seul) : l'absence de mesure n'est pas une erreur, elle
// prive juste le déchargement de son bilan chiffré.
// prive juste le déchargement de son bilan chiffré. Toujours une lecture
// FRAÎCHE : le bilan du déchargement (gpuSettle) en dépend, voir gpuStatsCached.
func gpuStats() []gpuStat {
out, err := hideCmd(exec.Command("nvidia-smi",
"--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu",
"--format=csv,noheader,nounits")).Output()
gpus, _ := gpuStatsErr()
return gpus
}
func gpuStatsErr() ([]gpuStat, error) {
out, err := nvidiaSmiQuery()
if err != nil {
return nil
return nil, err
}
var gpus []gpuStat
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
@@ -58,7 +86,65 @@ func gpuStats() []gpuStat {
g.Temp, _ = strconv.Atoi(parts[4])
gpus = append(gpus, g)
}
return gpus
return gpus, nil
}
// gpuCache : la lecture des jauges, partagée. Chaque onglet ouvert (et chaque
// téléphone) interroge /api/vram toutes les 3 s, et chacun lançait SON
// nvidia-smi — un processus qui réveille le pilote et vole quelques
// millisecondes de CPU aux threads de llama-server pendant une génération en
// partie sur CPU. Une lecture vieille de deux secondes suffit largement à une
// jauge ; le verrou tenu pendant la requête fait que des appels simultanés
// attendent la même lecture au lieu d'en lancer chacun une.
//
// Un nvidia-smi INTROUVABLE (Mac, CPU seul) est mémorisé une minute : inutile de
// rechercher le binaire dans le PATH toutes les deux secondes. Une autre erreur
// (carte en réinitialisation) se retente au bout du délai normal.
type gpuCache struct {
mu sync.Mutex
exp time.Time
gpus []gpuStat
}
const (
gpuCacheTTL = 2 * time.Second
gpuCacheAbsent = time.Minute
)
// get : now est l'horloge (time.Now hors tests).
func (c *gpuCache) get(now func() time.Time, query func() ([]gpuStat, error)) []gpuStat {
c.mu.Lock()
defer c.mu.Unlock()
if now().Before(c.exp) {
return c.gpus
}
gpus, err := query()
ttl := gpuCacheTTL
if errors.Is(err, exec.ErrNotFound) {
ttl = gpuCacheAbsent
}
// L'échéance part de la FIN de la requête : un nvidia-smi lent ne doit pas
// laisser une lecture déjà vieille passer pour neuve.
c.gpus, c.exp = gpus, now().Add(ttl)
return c.gpus
}
// invalidate : la prochaine lecture repart de nvidia-smi.
func (c *gpuCache) invalidate() {
c.mu.Lock()
c.exp = time.Time{}
c.mu.Unlock()
}
var gpuMonitor gpuCache
// gpuStatsCached : la lecture des jauges du moniteur (handleVram), SEULEMENT.
// Le déchargement garde gpuStats : gpuSettle conclut sur deux lectures égales
// de suite, et une valeur servie deux fois par le cache l'y ferait conclure à
// tort — exactement le faux bilan qu'il est écrit pour éviter. La tranche
// rendue est partagée : lecture seule.
func gpuStatsCached() []gpuStat {
return gpuMonitor.get(time.Now, gpuStatsErr)
}
// gpuUsedMB : VRAM occupée, toutes cartes confondues.
@@ -113,10 +199,13 @@ func gpuSettle(before int, sample func() (int, bool), pause time.Duration) int {
}
func gpuUsedSettled(before int) int {
return gpuSettle(before, func() (int, bool) {
g := gpuStats()
return gpuUsedMB(g), g != nil
}, 500*time.Millisecond)
return gpuSettle(before, gpuSettleSample, 500*time.Millisecond)
}
// gpuSettleSample : une lecture fraîche pour gpuSettle — jamais le cache.
func gpuSettleSample() (int, bool) {
g := gpuStats()
return gpuUsedMB(g), g != nil
}
// engineNeedsStop : faut-il envoyer « stop » ? Actif, évidemment. Mais sous
@@ -204,6 +293,9 @@ func handleVramUnload(w http.ResponseWriter, r *http.Request) {
if gpus != nil {
after = gpuUsedSettled(before)
}
// Les jauges ne doivent pas montrer, deux secondes encore, une carte pleine
// qu'on vient d'annoncer libérée.
gpuMonitor.invalidate()
freed := before - after
if freed < 0 {
freed = 0
+111
View File
@@ -1,10 +1,16 @@
package loki
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os/exec"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// gpuSettle : le bilan « VRAM libérée » est calculé sur ces lectures. Chaque cas
@@ -70,3 +76,108 @@ func TestVramRoutesRefusentGET(t *testing.T) {
}
}
}
// gpuCache : les jauges de tous les onglets se partagent une lecture par
// fenêtre de deux secondes, même quand les requêtes arrivent en même temps.
func TestGpuCache(t *testing.T) {
type step struct {
at time.Duration // horloge depuis le départ
err error // erreur rendue si la requête part
calls int32 // requêtes cumulées attendues après l'appel
}
absent := &exec.Error{Name: "nvidia-smi", Err: exec.ErrNotFound}
for _, c := range []struct {
name string
steps []step
}{
{"lecture réutilisée puis rafraîchie", []step{
{0, nil, 1}, {time.Second, nil, 1}, {gpuCacheTTL, nil, 2},
}},
// Carte en réinitialisation : on retente au délai normal, pas plus tard.
{"erreur retentée au délai normal", []step{
{0, errors.New("exit status 9"), 1}, {time.Second, nil, 1}, {gpuCacheTTL, nil, 2},
}},
// Pas de nvidia-smi du tout : une recherche par minute suffit.
{"binaire absent mémorisé", []step{
{0, absent, 1}, {30 * time.Second, absent, 1}, {gpuCacheAbsent, absent, 2},
}},
} {
t.Run(c.name, func(t *testing.T) {
var cache gpuCache
var calls int32
t0 := time.Unix(1000, 0)
for i, st := range c.steps {
now := func() time.Time { return t0.Add(st.at) }
cache.get(now, func() ([]gpuStat, error) {
atomic.AddInt32(&calls, 1)
if st.err != nil {
return nil, st.err
}
return []gpuStat{{Name: "fake", Used: 1000}}, nil
})
if got := atomic.LoadInt32(&calls); got != st.calls {
t.Fatalf("étape %d : %d requêtes, attendu %d", i, got, st.calls)
}
}
})
}
t.Run("appels simultanés", func(t *testing.T) {
var cache gpuCache
var calls int32
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
g := cache.get(time.Now, func() ([]gpuStat, error) {
atomic.AddInt32(&calls, 1)
time.Sleep(20 * time.Millisecond) // nvidia-smi n'est pas instantané
return []gpuStat{{Name: "fake", Used: 1000}}, nil
})
if len(g) != 1 || g[0].Used != 1000 {
t.Errorf("lecture partagée %+v", g)
}
}()
}
wg.Wait()
if calls != 1 {
t.Errorf("%d nvidia-smi lancés, attendu 1", calls)
}
})
}
// Le bilan du déchargement doit voir la mémoire PARTIR : son échantillonneur
// ne passe jamais par le cache des jauges, même tout juste rempli.
func TestGpuSettleSampleFrais(t *testing.T) {
orig := nvidiaSmiQuery
t.Cleanup(func() { nvidiaSmiQuery = orig; gpuMonitor = gpuCache{} })
var calls int32
nvidiaSmiQuery = func() ([]byte, error) {
n := atomic.AddInt32(&calls, 1)
return []byte(fmt.Sprintf("Fake GPU, %d, 24000, 0, 40\n", 20000-int(n)*1000)), nil
}
gpuMonitor = gpuCache{}
if g := gpuStatsCached(); len(g) != 1 || g[0].Used != 19000 {
t.Fatalf("jauges : %+v", g)
}
for want := 18000; want >= 17000; want -= 1000 {
if used, ok := gpuSettleSample(); !ok || used != want {
t.Errorf("échantillon = %d (%v), attendu %d frais", used, ok, want)
}
}
if g := gpuStatsCached(); g[0].Used != 19000 {
t.Errorf("jauges : %d, attendu la lecture en cache 19000", g[0].Used)
}
}
// Le cache ne mémorise une minute que l'ABSENCE de nvidia-smi : il faut donc que
// la vraie requête (bornée dans le temps) rende toujours exec.ErrNotFound quand
// le binaire n'est pas dans le PATH — sinon Mac et CPU seul relanceraient une
// recherche toutes les deux secondes.
func TestNvidiaSmiQueryAbsent(t *testing.T) {
t.Setenv("PATH", t.TempDir())
if _, err := nvidiaSmiQuery(); !errors.Is(err, exec.ErrNotFound) {
t.Fatalf("erreur %v, attendu exec.ErrNotFound", err)
}
}