Compare commits

...
Author SHA1 Message Date
MichaelandClaude Opus 5.5 4c2cf8b4e6 Façade : délai d'appel MCP par serveur et par argument, pas de relance au délai dépassé
Même règle que le Go (mcpCallTimeoutFor) : 120 s par défaut, remplacés par
callTimeoutSec du serveur, allongés par l'argument timeout de l'outil (+30 s,
millisecondes au-delà de 3600), plafond 30 min. Un délai dépassé rend la même
erreur que le Go sans reconnecter ni relancer l'outil. callTimeoutSec est lu
et réécrit tel quel dans la configuration des serveurs : un enregistrement
par la façade ne le perd plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:04 +02:00
MichaelandClaude Opus 5.5 68dd65cd5a Façade : motifs de relance vides écartés, comme le Go
Un motif vide (ou blanc) dans retry_patterns donnait (?mi), qui correspond à
tout : chaque tour était relancé une fois (OpenFox 8e9b4869). Sans un seul
motif utilisable, la façade retombe sur les motifs embarqués, comme
compileRetryPatterns côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:04 +02:00
MichaelandClaude Opus 5.5 01c11be4ae Correctifs repris d'AJEAN et d'OpenFox : LSP, shell, clé web, MCP, recall, compactage, relances
- LSP (lsp.go) : un signal resté dans le tampon par une republication
  tardive faisait rendre « aucun diagnostic » à l'édition suivante, avant même
  que le serveur ait vu la nouvelle version. Le canal est vidé, une
  publication d'une version antérieure est ignorée, et check attend 350 ms de
  calme après la dernière publication (liste vide puis analyse : c'est
  l'analyse qui compte), toujours borné par lspDiagWait. Au passage :
  interblocage latent levé (writeMsg → markDead reprenait mu, déjà tenu par
  check) — dead devient atomique.
- run_shell : ErrWaitDelay (commande finie, process laissé en arrière-plan
  sur les tubes) rend la sortie au lieu de « WaitDelay expired » qui
  l'effaçait (AJEAN 7403a8a0).
- Clé de pilotage web : lue depuis un cache à chaque requête au lieu d'ouvrir
  la base sous dbMu (AJEAN #120). Lectures réussies seulement, invalidé par
  toute écriture du bucket d'état, par la date/taille du fichier (autre
  process) et au bout d'une seconde.
- MCP : l'appel suit le contexte du tour (le bouton stop l'annule), délai par
  serveur (callTimeoutSec) et allongé par l'argument timeout de l'outil,
  plafonné à 30 min ; un délai dépassé n'est plus relancé (OpenFox 2ef584b8).
- Recall : les blocs d'une compaction s'archivent en une seule transaction
  au lieu d'une par bloc (AJEAN #130).
- Compactage en cours de tour : après un essai qui n'a rien réduit, pas de
  nouvel essai avant +5 % de la fenêtre (sauf à 90 %), pour les tâches et
  sous-agents aussi ; la cause d'un résumé raté est journalisée (AJEAN
  68659e73).
- Motifs de relance : un motif vide (qui correspondait à tout) est écarté,
  une surcharge sans motif utilisable retombe sur l'embarqué, et un
  changement de retry_patterns agit sans redémarrer (OpenFox 8e9b4869,
  c52c5bf7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:04 +02:00
LogiFlow 00e09a71e3 Lots 8 à 47 : migration Rust — routes, moteur, tâches, optimiseur, tour de la façade et tous les outils de l'agent (#33)
Lots 8 à 47 : migration Rust — routes, moteur, tâches, optimiseur, tour de la façade et tous les outils de l'agent
2026-10-16 09:31:59 +02:00
Claude cbbe62ff97 Tests : faux outils écrits par un sh à part (ETXTBSY)
plan_par_machine (lot 41) a vu « cpu » au lieu de « cuda » en CI : le faux
nvidia-smi, écrit par le process des tests puis lancé aussitôt, peut être
encore ouvert en écriture dans le fils qu'un test voisin vient de forker ;
son exec échoue alors sur ETXTBSY et l'outil paraît absent. Les scripts des
tests de la compilation et le faux Chrome (lot 44, lancé à côté des tests
parallèles de tests/lot6.rs) sont désormais écrits par un sh à part, comme
les autres faux binaires du dépôt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 07:26:36 +00:00
Claude e8563d992c Lot 47 : journal du sous-agent indépendant du poste
Le briefing machine des vues des rôles nomme l'hôte et le compte : masqués
dans le journal ({{HOTE}}, {{COMPTE}}), qui différait sur le poste de la CI.
Il nomme aussi le système et l'architecture : journal tiré sous Linux
seulement, comme les autres journaux qui dépendent du poste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 07:15:53 +00:00
Claude ded1c97c17 Lot 47 : outil subagent du mode Code exécuté par la façade
Le builder délègue à un rôle en lecture seule (explorer, code-reviewer,
planner) sans passer par /tool/exec : src/chat/subagent.rs porte
toolSubagent. Mêmes refus avant tout appel au moteur (rôle inconnu,
verifier compris, tâche vide, récursion), contexte isolé servi par le Go
(POST /internal/v1/turn/subagent : prompt du rôle + briefing machine,
outils, capacités ; subagentView et subagentFraming partagés), tour
imbriqué run_turn avec l'endpoint, le raisonnement, l'écho et l'arrêt du
tour qui délègue, température 0,6, travail annexe (second slot, effacé à
la fin), télémétrie à part. Seul le texte écrit après le dernier outil
revient, rogné, préfixé du rôle, tronqué à 6000 runes.

Le tour du rôle est porté par SubHost, qui délègue tout à son GoTurnHost
sauf un nouvel appel à subagent ; les outils de la façade passent dans
GoTurnHost::exec_tool_base, que seul le tour principal complète de
subagent (le type du tour imbriqué ne se contient pas lui-même).

Parité : journal tiré du Go contre un faux moteur scripté
(parity_subagent_test.go → tests/parity_subagent/subagent.json : refus,
réponse directe, rapport après deux outils, texte vide relancé, rapport
tronqué, cadrage du planner, moteur qui refuse ; requêtes du rôle),
rejoué par un tour complet de la façade (tests/lot6.rs) ;
TestSubagentVueInterne côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 07:12:29 +00:00
Claude 92e9adee7d Lot 46 : outils recall et recall_search exécutés par la façade
Les deux outils qui rappellent un bloc archivé par la compaction ne passent
plus par /tool/exec du Go : src/recall.rs porte toolRecall et
toolRecallSearch (id tolérant comme recallIDToSeq, mots-clés en minuscules
rune par rune comme strings.ToLower, score libellé double et plus récent
d'abord, limites, extraits et textes du Go). L'archive reste au Go, seul à
tenir la séquence de la base, qui la sert à la façade
(GET /internal/v1/recall/block?seq=N et /internal/v1/recall/blocks, blocs
chiffrés sous une mémoire verrouillée exclus) ; ses propres outils passent
par les mêmes lectures. Go injoignable : l'appel passe par lui.

Parité : journal de 41 appels sur une archive de 35 blocs tiré du Go
(parity_recall_test.go → tests/parity_recall/recall.json), rejoué par
tests/recall.rs ; un tour de la façade qui rappelle et cherche
(tests/lot6.rs) ; TestRecallInterneLecture côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 06:54:35 +00:00
Claude db1f20c0a4 Lot 45 : outils d'image (see_image, web_screenshot) tenus par la façade
La façade exécute les deux outils qui montrent une image au modèle
(src/imagetools.rs, port de chat_vision_tool.go, chat_screenshot.go et
prepareImageForModel) : préparation de l'image comme les pièces jointes
(orientation EXIF, grand côté sous 1568 px, mêmes pixels), sonde du moteur
(/props authentifié, gardée 10 s ; preset externe : sa case), see_image aux
textes du Go, web_screenshot par Playwright (adresses locales quand internet
est coupé, mêmes arguments, erreur résumée, capture rangée dans le dossier
de la discussion, ménage à 20 captures et 40 Mo, relayée au modèle s'il
voit les images). Le Go garde les siens pour ses sous-agents et tâches IA.

Parité : journal tiré du Go avec ses fichiers, un faux moteur et un faux
Playwright (tests/parity_images/images.json), rejoué par
tests/imagetools.rs ; un tour de la façade sans passer par le Go
(tests/lot6.rs) ; tests unitaires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 06:36:08 +00:00
Claude fe51bdeee9 Lot 44 : navigateur piloté tenu par la façade
La façade tient la session Chrome des outils browser_* (src/browser/,
port de computer_cdp.go, computer_use.go, browser_grid.go et
cu_autoshot.go) : lancement (profil temporaire, mêmes arguments, tué avec
la façade), client CDP multiplexé sur websocket, attente du chargement et
du calme réseau, scripts injectés copiés à l'octet près, les neuf outils
aux textes du Go (snapshot dédoublonné, recherche, clics, saisie et listes
déroulantes, touches, défilement, navigation et ses erreurs), capture
rangée propre et quadrillée pour le modèle (mêmes pixels), aperçus JPEG en
anneau servis par /api/chat/cushot.

Un seul navigateur : quand la façade tient la conversation, le Go lui
passe les outils de ses sous-agents et tâches IA, ses aperçus et la
fermeture (POST /internal/facade/v1/browser) ; façade injoignable, le
navigateur du Go prend le relais. Couper l'interrupteur ferme les deux.

Parité : journal de 42 étapes tiré du Go contre un faux Chrome (script +
faux serveur CDP qui note chaque commande, scripts résumés par leur
empreinte, pixels de la grille), rejoué par tests/browser.rs ; un vrai
Chromium de bout en bout avec LOKI_CU_SMOKE=1 ; un tour de la façade qui
ouvre une page (tests/lot6.rs) ; TestFacadeNavigateur côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 06:17:51 +00:00
Claude 7288db632e Lot 43 : jobs d'arrière-plan du mode Code tenus par la façade
bash_bg et bash_tail des tours de la façade ne passent plus par /tool/exec
du Go : src/tools/jobs.rs porte code_jobs.go. Refus avant lancement
(commande vide ou dangereuse, optimisation en cours), journal
.loki/jobs/<id>.log dans le dossier de la discussion, processus qui survit
au tour, shell sans le jeton interne ; relecture bornée (40 lignes par
défaut, 200 au plus), état et durée, arrêt relu aussitôt comme le Go.
paths::go_mkdir_all reproduit os.MkdirAll, erreur comprise.

Les jobs d'une discussion supprimée s'arrêtent avec elle. L'optimiseur du
Go compte ceux de la façade (POST /internal/facade/v1/bgjobs) avant de
mesurer ; façade injoignable : les siens seulement.

Parité : journal de 24 étapes tiré du Go (parity_jobs_test.go →
tests/parity_jobs/jobs.json), rejoué par tests/jobs.rs ; un tour de la
façade qui lance un job (tests/lot6.rs) ; TestFacadeJobsArrierePlan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 05:42:32 +00:00
Claude f66d1b7621 Lot 42 : outils web de l'agent exécutés par la façade
Les outils web des tours de la façade (web_search, web_images, web_open,
web_read, web_grep) ne passent plus par /tool/exec du Go : src/webtools.rs
porte chat_internet_tools.go, chat_internet.go et chat_web_images.go. Le
cache des pages (10 min, clé MD5 des options comme le Go) est dans la
façade ; client Crawl4AI au corps près, réponses lues comme json.Unmarshal ;
résultats DuckDuckGo tirés du markdown (uddg, entités, extraits) ;
recherche d'images avec le jeton vqd et les cookies gardés par hôte ;
web_grep sur les regex du Go, erreurs au texte de regexp.

Le moteur intégré lit le HTML avec go-readability et html-to-markdown, que
rien en Rust ne reproduit au caractère près : la conversion d'une page et la
lecture des résultats restent au Go, derrière /internal/v1/web/fetch et
/internal/v1/web/search (sans état). web_screenshot reste au Go.

Le traducteur go_regex refuse la répétition d'une répétition et les comptes
au-delà de 1000, et rend littéral un « { » qui n'ouvre pas de répétition,
comme le Go (motifs retry_patterns compris).

Parité : journal de 74 appels tiré du Go (parity_web_tools_test.go →
tests/parity_web/outils.json : résultats, requêtes reçues par les faux
Crawl4AI et DuckDuckGo, pages demandées au moteur intégré), rejoué par
tests/webtools.rs ; un tour de la façade qui ouvre une page (tests/lot6.rs) ;
TestInternalMoteurWebIntegre côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 05:29:35 +00:00
Claude bed6d07739 Optimiseur : l'essai muet du banc tient son port
L'essai qui ne répond jamais (tests/tune.rs, essai_muet) ne liait pas le
port choisi par l'optimiseur : pendant les 2 s d'attente, un autre test
lancé en parallèle pouvait le prendre et répondre à /health à sa place
(« /props illisible » au lieu de « pas prêt après 2s », vu sur la CI). Il
le lie désormais sans jamais accepter, comme un moteur bloqué au
chargement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 04:56:31 +00:00
Claude 5197ee2542 Lot 41 : compilation de llama.cpp depuis les sources tenue par la façade
Avec LOKI_BACKENDS=rust, la façade compile elle-même llama.cpp sous Linux
et macOS (backends/build.rs) : install {force}, update {clean} et
install-custom {repo, name, ref}, port de backend_build.go, des jobs
lcRunInstall / lcRunUpdate / lcBuildAndSwitch et d'installCustomBackend.

- plan complet (Metal, CUDA avec racine du toolkit et architectures, HIP,
  Vulkan), build/ déplacé ou propre mis de côté en build.old ;
- étapes suivies : sorties des deux flux dans un même tube, découpage du
  sinkWriter (git) et du scanner (CMake), configure.log et build.log,
  fin des journaux remontée en cas d'échec ;
- outils manquants installés au mieux, compilateur vérifié ;
- service moteur arrêté le temps du build et relancé même en échec ;
- refus comme le Go : moteur de l'image, URL absente, optimisation en
  cours, job déjà lancé.

Côté Go, facadeBackendsRoutes gagne les trois routes hors Windows (la
chaîne MSVC reste au Go, qui rapporte ses étapes au job de la façade).

Parité : internal/loki/parity_build_test.go tire un journal de 15 étapes
(tests/parity_backends/build.json) que tests/backends.rs rejoue par la
façade ; tests unitaires du découpage, du plan, du cache et des erreurs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 04:54:32 +00:00
Claude 90d6dd4a9a Lot 40 : outils mémoire de l'agent exécutés par la façade
Quand la façade tient la mémoire (LOKI_MEM=rust), les outils mem_search,
mem_read, mem_add, mem_edit et tracker de ses tours s'exécutent chez elle
(src/memory/tools.rs, port de chat_memory.go et de l'outil de tracker.go) :
mêmes résultats et diffs, mêmes lectures et écritures que les routes de la
mémoire (scellées selon le chiffrement, .bak, index tenu par le code, seed
au nom du projet). Recherche classée par couverture puis pertinence,
extrait coupé à l'octet comme le Go ; trackers vus niveau par niveau.

ChatShared porte la même mémoire que les routes ; tracker_add prend l'heure
en paramètre (tracker_add_at). Côté Go, l'heure des trackers passe par
trackerNow, que le journal fixe.

Parité : journal de 57 appels tiré du Go (parity_mem_tools_test.go →
tests/parity_mem/outils.json : résultats, diffs, fichiers de la mémoire et
trackers), rejoué par tests/memory.rs ; un tour de la façade qui crée une
page (tests/lot6.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 04:29:53 +00:00
Claude 35886da9b1 Lot 39 : anneau de télémétrie et /api/perf/summary tenus par la façade
L'anneau des complétions (perf_log.go) est porté en Rust (src/perfring.rs) :
perte de cache contre la complétion précédente de la même discussion et de
la même nature, ce qui s'est intercalé, seuil d'alerte du preset (dans
l'ordre du lancement, 2048 sur un modèle hybride), ligne [perf], résumé de
/api/perf/summary. ChatShared le porte : tours, vérifications, résumés de
compaction et préchauffage de la façade y rangent leurs complétions sans
aller-retour au Go.

Côté Go, quand la façade tient la conversation, perfRecord range chez elle
(/internal/facade/v1/perf/record) et perfSnapshot relit son anneau
(optimiseur) ; /api/perf/summary lui est remis. Façade injoignable : l'anneau
du Go prend le relais. La route /internal/v1/turn/perf, plus appelée, est
retirée.

Parité : journal tiré du Go (parity_perf_test.go → tests/parity_perf/
perf.json : 14 complétions, résumé, onze seuils), rejoué par
src/perfring/tests.rs ; deux tours qui perdent le cache (tests/lot6.rs),
l'API de l'anneau pour le Go, et TestFacadePerfAnneau côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 04:17:04 +00:00
Claude 76d9dce74a Lot 38 : l'outil criteria du mode Code exécuté par la façade
L'outil par lequel le modèle tient les critères d'acceptation de sa tâche
(poser, marquer faits, lister, effacer) partait au Go, qui renvoyait la
liste à la façade comme delta. Il s'exécute maintenant dans le tour de la
façade (chat/crittool.rs, port de toolCriteria) : mêmes messages, ids en
nombre ou en chaîne, vingt critères au plus, `passed` réservé à la passe de
vérification, rangement scellé (chat/crit:<id>), liste poussée à l'écran à
chaque changement par un nouveau champ `deltas` de l'hôte du tour.

Les critères sont rangés sous la discussion du tour (le Go prenait la
discussion active : elles ne diffèrent que si l'utilisateur en change
pendant la génération). see_image reste au Go (préparation de l'image).

Parité : journal de 22 appels tiré du Go (parity_criteria_test.go →
tests/parity_tools/criteria.json), rejoué par tests/tools.rs ; le contrat
du mode Code de tests/lot6.rs est désormais vérifié par la façade seule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 04:01:03 +00:00
Claude 1d33ca5a49 Lot 37 : journal des outils task_* réservé à Linux, comme celui des tâches
Il emprunte parityTaskIDRe et parityApplyFiles aux journaux Linux : go vet
échouait sur macOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 03:59:28 +00:00
Claude e94b625931 Lot 37 : outils task_* de l'agent exécutés par la façade
Quand la façade tient les tâches (LOKI_TASKS=rust), les quatre outils par
lesquels le modèle gère ses tâches planifiées — task_list, task_create,
task_update, task_delete — s'exécutent dans ses tours (src/tasks/tools.rs,
port de tasks_tools.go), sans passer par le Go : mêmes messages, projet
actif, fuseau de l'utilisateur, tâche unique « dans N minutes » ou « à
HH:MM », fréquence validée, script vérifié, compte-rendu coupé comme le Go.
ChatShared porte la même base de tâches que les routes.

Côté Go, les outils prennent l'heure des tâches (tasksNow) au lieu de
time.Now : rien ne change en service, le journal de parité la fixe.

Parité : journal de 44 appels tiré du Go
(parity_tasks_tools_test.go → tests/parity_tasks/tools.json), rejoué par
tests/tasks.rs ; un tour de la façade qui crée une tâche (tests/lot6.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 03:51:09 +00:00
Claude 869bd18bff Lot 36 : appels d'outils MCP du tour de la façade par son pool
Quand la façade tient les serveurs MCP (LOKI_MCP=rust), un appel mcp__*
d'un de ses tours partait au Go (/internal/v1/tool/exec), qui le renvoyait
aussitôt à la façade (/internal/facade/v1/mcp/call) : deux allers-retours
pour revenir au même pool. Le Go n'ajoute rien au résultat d'un outil MCP.

ChatShared porte désormais le pool (le même que celui des routes, créé une
fois) ; GoTurnHost::exec_tool l'appelle directement — même résultat que la
route interne, reconnexion comprise. Une reconnexion annonce la version du
Go (/internal/v1/auth), lue une fois et retenue. Sans pool tenu, rien ne
change. Le Go garde la route interne pour les tours qu'il exécute encore.

Test : un tour de la façade contre un faux serveur MCP stdio — rien par le
Go, résultats tels que le modèle les reçoit, erreur après reconnexion,
version du Go annoncée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 03:47:58 +00:00
Claude 9dfce204bf Lot 28 : tests contre les mutations survivantes de l'optimiseur
105 mutations du verrou et du job ; le premier passage en tuait 39. Les
survivantes le sont désormais par des tests ajoutés :

- l'essai noté dans le verrou pendant la mesure, la sauvegarde notée avant
  d'écrire, l'application refusée pendant la mesure, la cible inconnue ;
- l'application gardée : preset qui n'est plus en service, API externe,
  clé changée ailleurs pendant l'application (défaite) ;
- l'application planifiée : clé sur option refusée sans l'option ;
- l'essai muet (délai de chargement), /props sans
  default_generation_settings, SIDE_SLOT sur un seul slot, threads comptés
  sur les cœurs de la sonde, aucun changement de moteur signalé quand son
  build est inconnu ;
- VRAM mesurée par un faux nvidia-smi, budget, profondeur, notes et poids,
  écarts de mesure, essais écartés, moteur piloté par la façade.

nvidia-smi, la pause entre deux lectures de VRAM et l'unité du budget
deviennent réglables (tests). Le test de l'essai orphelin lit l'identité
du processus une fois l'exec visible dans /proc : lue trop tôt, elle
portait l'exécutable du test (échec intermittent en CI).

Les vingt survivantes restantes, équivalentes ou hors de portée d'un test
(courses, estimation seulement), sont décrites dans la spec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 03:46:44 +00:00
Claude c157da2141 Lot 35 : routes de base de la façade aussi par le tunnel du relais
Les neuf routes que la façade sert toujours (ping, status, config, ligne
de commande du moteur, couches du modèle, chemins, RAM, VRAM, journal du
service) arrivent désormais à la façade par le tunnel du relais aussi :
withFacadeChat les lui remet dès qu'elle tourne (facade_core.go). Les
handlers du Go ne servent plus que sans façade (LOKI_FACADE=0).

Côté Rust, une requête relayée par le Go (jeton interne) n'est pas
réauthentifiée, comme pour les routes des lots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 03:19:00 +00:00
Claude ad52da18a9 Lot 34 : bloc projet figé (PROJ_SNAPSHOT) dans le tour de la façade
PROJ_SNAPSHOT était le dernier réglage qui gardait la conversation au
Go : la façade la tient désormais dans tous les cas (LOKI_CHAT=go reste
la porte de sortie). facadeChatBlocker et la décision prise une fois par
processus disparaissent.

Go (facade_projsnap.go) :
- /internal/v1/turn/view, avec la clé et l'agent : tête du tour portant
  le bloc figé (ou le vivant à rafraîchir), préambule sans date ni
  dossier, et la décision projsnap (rafraîchir, mise à jour à poser,
  instantané à ranger, clé et système du tour) ; dry pour le
  préchauffage ;
- /internal/v1/turn/projsnap/refresh : bloc vivant et instantané qu'il
  devient, pour une compaction en plein tour.

Rust :
- ChatService::assemble_turn, partagé par le tour et la relance du
  builder : instantané rangé avec la discussion, mise à jour posée en tête
  du dernier message utilisateur (prependCtxUpdate), historique nettoyé
  au rafraîchissement ;
- TurnHost::project_refresh après une compaction ou une réduction en
  plein tour (replaceProjectHead) ; l'instantané posé devient celui de la
  discussion ;
- le préchauffage demande la vue en dry et garde les mises à jour tant
  que le bloc n'est pas rafraîchi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 03:13:27 +00:00
Claude ff5c6d4571 Lot 33 : préchauffage (PREWARM) tenu par la façade
PREWARM ne garde plus la conversation au Go. La façade tient
l'historique : elle assemble et envoie la requête du prochain tour ; le
Go garde le compteur des requêtes en vol et ce qui l'annule.

Go (facade_prewarm.go) :
- /internal/v1/prewarm/check : moteur libre d'un vrai travail, loin de
  minuit, en bonne santé, slot visé ;
- /internal/v1/prewarm/begin : rechargement SLOT_PERSIST puis inscription
  comme LA requête en vol ; son annulation est relayée à la façade ;
- /internal/v1/prewarm/end : place rendue, cache repris noté ;
- /internal/v1/oai/begin : prewarm_keep pour l'étape qui le prolonge ;
- fin de tâche planifiée : le déclencheur part à la façade.

Rust (chat/prewarm.rs) :
- assemblage par les mêmes fonctions que le tour, sentinelle « . », un
  seul jeton, non streamé ; abstention (capacités inconnues, tour en
  cours, discussion vide, compaction attendue, assemblage inattendu) ;
- déclencheurs : fin de tour, fin de tâche, changement de discussion ou
  de projet (full : après 3 s) ;
- le tour garde le préchauffage qu'il prolonge (empreintes des messages
  et des options de rendu) et coupe tout de suite les autres ;
- télémétrie kind=prewarm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 02:52:44 +00:00
Claude f79b74dac3 Lot 32 : images gardées (KEEP_TURN_IMAGES) dans le tour de la façade
KEEP_TURN_IMAGES ne garde plus la conversation au Go. L'état du tour vit
dans la façade ; les fichiers et la vision restent au Go.

Go (facade_keepimg.go) :
- /internal/v1/turn/keepimg : vision réellement active, budget des images
  gardées ;
- /internal/v1/turn/keepimg/store : une image relayée rangée par
  référence (refRelayImage), relue à l'octet près.

Rust :
- chat/keepimg.rs : port de keepImages (image à l'essai, mesure par le
  moteur répartie au prorata des octets, budget, publication, fin de tour).
- La boucle du tour : budget demandé pour le fil principal, image rangée
  par le Go, historique réécrit publié sans les relais non gardés ; les
  images gardées partent avant tout résumé (filet réactif, en cours de
  tour, fenêtre pleine).
- Début et fin de tour : retirées si le contexte le demande ou si elles
  ne doivent plus partir (clé retirée, vision absente).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 02:30:59 +00:00
Claude a005626e66 Lot 31 : slots du moteur pour le tour tenu par la façade
SIDE_SLOT et SLOT_PERSIST ne gardent plus la conversation au Go. La
machinerie reste au Go (configuration, requêtes en vol, tampon du slot) ;
la façade la consulte avant chaque requête au moteur local.

Go :
- /internal/v1/turn/slot (facade_slots.go) rend engineSlotFor et, pour
  une étape de tour, recharge d'abord l'état gardé de la discussion
  (slotPersistRestore). La sauvegarde à la bascule voit les étapes de la
  façade : /internal/v1/oai/served marque le slot (engineMarkMain).
- facadeChatBlocker : SIDE_SLOT et SLOT_PERSIST retirés.

Rust :
- TurnHost::engine_slot : l'id_slot posé dans chaque requête (0 pour le
  fil de la discussion, 1 pour la vérification), le ticket « en vol »
  ouvert sur le second slot.
- Cache KV plein en plein calcul (sideSlotPoolError) : rejoué deux fois
  tel quel, sans compaction, puis l'erreur.
- Résumés de compaction comptés en vol comme postSummary : transcription
  sur le second slot, continuation sur le slot 0.
- La passe de vérification est un travail annexe (engineSideJob) : le Go
  efface le slot à la fin s'il a servi (écart du lot 7 levé).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 02:13:20 +00:00
Claude 89ed527c96 Fusion de main : conversation Rust par défaut, télémétrie du tour
main a reçu en parallèle la télémétrie du tour tenu par la façade
(/internal/v1/turn/perf), les tickets « en vol » du moteur et la
conversation tenue par la façade par défaut (LOKI_CHAT=go pour revenir
au Go). Le lot 29 faisait la même chose par /internal/v1/perf/record :
un seul chemin est gardé, celui de main.

- TurnHost::perf_record (main) remplace TurnHost::perf ; la nature est
  posée par l'hôte (verify pour la passe isolée seulement, main pour les
  corrections du builder, comme le Go).
- Les résumés de compaction passent par le même point d'entrée, nature
  compact : chat/perf.rs ne garde que le relevé de la réponse entière
  (perfRecFromWire) et l'envoi.
- La sonde du gabarit reste à la façade (lot 23) : /internal/v1/turn/perf
  ne la lance plus côté Go.
- facade_perf.go retiré ; ses cas (perte, intercalé, alerte, preset
  externe, incomplète, GET) rejoignent web_internal_turnperf_test.go.
- README : LOKI_CHAT défaut Rust, avec ce que les lots 19, 23, 29 et 30
  y ajoutent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 01:53:53 +00:00
Claude e444202b4b Lot 30 : motifs de relance personnalisés tenus par la façade
La surcharge retry_patterns (tableau JSON de regex du Go, chacune en
(?mi)) ne garde plus la conversation au Go : la façade la lit au
démarrage de sa conversation, comme le Go la compile une fois par
processus ; illisible ou vide, les motifs embarqués s'appliquent, une
regex illisible est sautée.

Les regex restent celles du Go : chat::stream::go_regex réécrit pour le
crate regex ce qui n'a pas le même sens — classes Perl et limites de mot
ASCII (\w \d \s \b et leurs contraires, dans et hors d'une classe),
\Q…\E (refusé dans une classe, comme le Go), [ && -- ~~ dans une classe,
octal permis, référence arrière refusée.

Parité : parity_retry_test.go compile côté Go une soixantaine de regex
(dont celles qu'il refuse) et note leur première correspondance sur onze
textes (accents, chiffres non ASCII, espaces insécables, crochets) ;
tests/retry.rs rejoue le journal à l'identique et vérifie la lecture de
la surcharge au démarrage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 01:24:16 +00:00
Claude 8a61879bfd Lot 29 : télémétrie des complétions de la façade
Quand la façade tient la conversation (LOKI_CHAT=rust), ses complétions
rejoignent l'anneau de télémétrie du Go (perf_log.go) au lieu de passer
inaperçues : /api/perf/summary les compte, l'optimiseur en tire ses
poids, et l'interface reçoit à nouveau la perte de cache.

- chat/perf.rs : perfRecFromStats, perfRecFromWire (lu sans échec
  possible, mêmes tolérances que perfNum, timings d'un autre type
  présents mais vides comme le Go) et perfPrefix (FNV-1a cumulé) ;
- la boucle du tour range chaque complétion lue, numérotée dans le tour,
  avec ses empreintes si elle est complète ; une complétion coupée par un
  stop est rangée incomplète ; la dernière statistique porte la nature,
  la perte, ce qui s'est intercalé et l'alerte ;
- natures : main (tour), verify (vérification du mode Code), compact
  (résumé de compaction, rangé dès que la réponse se lit) ;
- le Go (/internal/v1/perf/record) range le relevé comme les siens et
  calcule l'alerte au seuil du preset actif, jamais pour un preset
  externe.

Tests : relevés et empreintes (chat/perf.rs), télémétrie de la boucle
(complète, coupée), du vrai hôte et des résumés (tests/lot6.rs),
TestFacadePerf côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 01:17:49 +00:00
Claude f6c191922f Lot 28 : optimiseur tenu par la façade (LOKI_TUNE=rust)
Avec LOKI_TUNE=rust (expérimental, Unix seulement), la façade sert
/api/tune, /api/tune/status, /api/tune/cancel, /api/tune/apply et
/api/tune/last, et déroule l'optimisation sans perte (rust/loki-rs/src/tune) :
vrai moteur arrêté, essais composés à blanc puis lancés par les « loki
serve » du Go (LOKI_TUNE_TRIAL), chacun dans son groupe de processus,
mesurés par le bench complet à profondeur fixe, retenus au-delà du
bruit ; vrai moteur relancé. Rien n'est écrit sans un clic : copie du
preset, ou le preset lui-même — redémarrage, sonde, et ancienne version
rétablie si elle échoue.

- tune/core.rs : la partie pure (EXTRA_ARGS, réglages, essais de chaque
  étape, garde-fous, diff et réécriture du preset, score) ;
- tune/lock.rs : le verrou tune.lock au format du Go, identité des
  processus comme procIdentity (la reprise du Go reconnaît la façade
  vivante, et écarte son verrou si elle meurt), ménage d'un verrou
  périmé (essai orphelin arrêté, application interrompue défaite) ;
- tune/job.rs : routes, job, essais et application.

Le verrou de génération reste celui du Go, pris par le bail du moteur au
motif « tune » (refus de l'optimiseur pour les autres, stop du chat qui
annule). Le Go prête /internal/v1/tune/context (bench, génération, jobs
du mode code, état du moteur, télémétrie) et le démarrage ou l'arrêt du
moteur sans le refus de l'optimiseur (service/action « os »). Hors
Unix, la façade passe LOKI_TUNE vide au Go, qui garde l'optimiseur.

serde_json lit désormais les flottants à l'arrondi exact
(float_roundtrip), comme le Go.

Parité : parity_tune_test.go tire du Go un journal (partie pure, contenu
du verrou, résultat enregistré, vérification après application) rejoué
par tests/tune_core.rs. tests/tune.rs déroule de vraies optimisations, le
binaire de test jouant « loki serve » ; les tests du verrou du Go sont
portés ; TestFacadeTune côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 01:06:31 +00:00
Claude 84ae3a0cc4 Lot 27 : tests contre les mutations survivantes du banc d'essai
84 mutations du port Rust, 81 tuées ; trois équivalentes documentées
dans la spécification (annulation déjà vue au premier tour du select,
progression d'un job remplacé, PID nul du moteur).

- bench/tests.rs : un mini moteur qui garde les corps des requêtes ;
  tranche pile à la fin du corpus, tour sans attendu, débits nuls
  recalculés, niveau rejoué une seule fois, préchauffage et ligne courte
  d'un run rapide, profondeur arrêtée (contexte plein, corpus épuisé,
  réponse reprise, note de reprise hybride ou gabarit) ;
- tests/bench.rs : second lancement pendant la prise du bail, preset
  relu au départ du job, mesure enregistrée sous le preset actif ;
- journal de parité : un prompt juste au-delà de la prose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 00:41:20 +00:00
Claude 8f61870224 Lot 27 : banc d'essai tenu par la façade (LOKI_BENCH=rust)
Avec LOKI_BENCH=rust (expérimental), la façade sert /api/bench,
/api/bench/status, /api/bench/cancel et /api/bench/last, et déroule le
protocole de mesure contre le moteur local (rust/loki-rs/src/bench) :
mode rapide (préchauffage, ligne courte) et mode complet (prefill à froid
à profondeur réelle, trois tours qui reprennent le cache). Corpus,
requêtes, calculs et messages sont ceux du Go ; seule une mesure complète
est enregistrée (last_bench, bench_presets, empreinte de configuration du
Go).

Le verrou de génération reste celui de la conversation du Go : la façade
le prend par le bail du moteur, avec le motif « bench ». Le Go note alors
benching et donne aux autres le refus du benchmark ; changer de
discussion ne rompt pas le bail ; benchRunning() le voit (clients /v1,
préchauffage, optimiseur). Un stop du chat, un Reset ou un bail perdu
annulent le bench. Le Go prête aussi le travail annexe (effacement du
slot à la fin, /internal/v1/engine/sidejob) et la télémétrie
(/internal/v1/bench/perf).

Correction des tests du Go : trois tests du préchauffage échouaient dix
minutes par jour, avant minuit (prewarmNearMidnight lisait l'heure
réelle). L'heure du garde-fou devient remplaçable (prewarmNow), fixée à
midi dans les tests.

Parité : parity_bench_test.go tire du Go un journal rejoué par
tests/bench.rs contre le même faux moteur ; chaque requête reçue par le
moteur est comparée (longs textes réduits à leur taille et à leur
empreinte). Il couvre les refus, les deux modes, les échecs de chaque
phase, le raisonnement refusé puis rejoué, la profondeur partielle et
l'annulation. Les tests du Go sont portés (bench/tests.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 00:09:09 +00:00
Claude 9a0455be8f Lot 26 : tâches planifiées tenues par la façade (LOKI_TASKS=rust)
Avec LOKI_TASKS=rust (expérimental), la façade sert les routes
/api/tasks* et fait tourner le planificateur, que le Go ne démarre plus.
Elle exécute elle-même les tâches « script seul » (shell de l'agent,
limite de 10 min, arrêt à la main). Une tâche IA passe par le verrou de
génération et les outils de l'agent, restés au Go : la façade la lui fait
exécuter (/internal/v1/tasks/run), comme son propre planificateur l'aurait
fait. Les notifications partent par la façade si elle les tient, sinon par
le Go (/internal/v1/push/send).

- src/tz.rs : les fuseaux IANA comme le Go (TZif v1/v2 et règle POSIX,
  time.Local, time.Date et ses heures qui sautent ou se répètent) ;
- tasks/cron.rs : les quatre formes de fréquence, avec ParseDuration du Go
  et ses messages d'erreur ; instants sur 128 bits (dates au-delà de
  2262) ;
- tasks/mod.rs : la base, l'exécution, le planificateur (un tic par
  minute, une tâche due par tic) et les sept routes.

Le Go reçoit un crochet de test pour l'heure des tâches (tasksNow).

Correction de la façade : les appels à l'API interne du Go étaient tous
bornés à 10 s. Un outil exécuté par le Go qui dure plus longtemps (page
lente, navigateur, MCP tenu par le Go) était coupé, alors qu'il continuait.
Ces appels sont désormais bornés à 30 min ; une tâche IA est attendue
jusqu'à 24 h.

Parité : parity_tasks_test.go tire du Go un journal rejoué par
tests/tasks.rs, l'heure des tâches fixée pas à pas. Il couvre
l'enregistrement (chaque forme et chaque erreur de fréquence, plusieurs
fuseaux), la bascule, l'interrupteur maître, la suppression, les
lancements et les tics du planificateur. Les mutations (79, dont 77
tuées et 2 équivalentes) ont fait trouver et corriger cinq écarts du
port : dates lointaines, intervalle en jours démesuré, heure qui saute au
printemps, compte-rendu coupé au milieu d'un caractère, noms de fuseau
contenant « .. ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 23:30:03 +00:00
Claude ed9b2e8c1c Lot 25 : Strata tenu par la façade (LOKI_STRATA=rust)
Port de backend_strata.go (rust/loki-rs/src/strata) : la machine (cartes
par nvidia-smi, Python 3.10+ et venv, RAM totale et disponible, RAM du
moteur Strata en marche, place libre), ce qui y tient et la
recommandation, la configuration de lancement en clair des modèles
installés (placement des experts drop/ram/arena/disk, carte d'aide,
réglages du preset), les réglages d'un modèle installé et l'installation
(job des moteurs : paquet figé vérifié par SHA-256, installeur lancé et
journalisé comme le sinkWriter du Go, preset écrit puis activé par le Go).

Tenu seulement avec LOKI_BACKENDS=rust ; le lancement (loki serve) reste
au Go, qui relaie les routes du tunnel.

Correction du Go : /api/strata/settings décidait « en service » après
avoir écrit les nouveaux réglages, donc jamais pour un réglage changé ;
décidé désormais avant l'écriture, comme SavePresetApplying. Le port suit.

Parité : parity_strata_test.go (crochets de test : release, meminfo,
place libre, action de service) tire du Go un journal rejoué par
tests/strata.rs ; les tests du Go portés dans strata/tests.rs ;
TestFacadeStrata côté Go. Mutations : 71 tuées sur 71.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 22:43:04 +00:00
Claude 8c9bb6b450 Lot 24 : moteur de l'image tenu par la façade (LOKI_ENGINE_IMAGE=rust)
Port de web_engine.go, web_engine_reco.go et backend_engine_oci.go
(rust/loki-rs/src/engineimg) : statut du moteur (build, source, variante,
versions installées, avis, version recommandée, version précédente,
relevé du rendu), dernier build publié, plan (dernière ou recommandée,
risque de rendu du raisonnement au défaut de b10763), mise à jour depuis
le registre officiel (couches du sommet, liens, runtime ignoré, essai à
blanc, REASONING_PRESERVE=off dans le preset actif ou la configuration,
ménage, vérification du rendu avant/après par la sonde de gabarit),
bascules, suppression et retour à la version précédente.

Tenu seulement avec LOKI_BACKENDS=rust : la mise à jour est un job des
moteurs. La sonde est celle de la conversation quand la façade la tient.
/api/engine/cmdline reste au Go, qui relaie les routes du tunnel.

Parité : parity_engineimg_test.go tire du Go un journal (faux registre
OCI, faux moteur local pour la sonde) rejoué par tests/engineimg.rs ;
TestFacadeMoteurImage côté Go. Les aides de fichiers des rejeux passent
dans tests/common/parity.rs. Mutations : 71 tuées sur 74, trois
équivalentes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 22:15:30 +00:00
Claude 0f3a3d52c1 Lot 23 : sonde de gabarit en Rust (avec LOKI_CHAT=rust)
Port de chat_tplprobe.go : la façade sonde le gabarit du moteur après ses
complétions (/health, /props, /apply-template), avec les verdicts
preserves_history, prefix_stable et renders_reasoning, le cache par
build/modèle/gabarit/forme et la relance bornée.

- l'écho du raisonnement suit le verdict de la sonde (echo_policy_for) ;
- NUDGE_IN_TOOL ne garde plus la conversation au Go : le rappel de budget
  va dans le dernier résultat d'outil quand le préfixe n'est pas stable ;
- le Go lit le verdict de la façade (/internal/facade/v1/chat/tpl) quand
  elle tient la conversation.

Parité : parity_tplprobe_test.go tire du Go un journal d'échanges rejoué
octet pour octet par tests/tplprobe.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 21:34:57 +00:00
Claude 78766a28d9 Tests des backends : resetBackendsState hors du fichier réservé à Linux
TestFacadeBackends tourne sur toutes les plateformes (job macOS de la CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 21:08:37 +00:00
Claude 2c926a8274 Lot 22 : backends llama.cpp et job des moteurs tenus par la façade (LOKI_BACKENDS=rust)
La façade (rust/loki-rs/src/backends) sert le statut du moteur llama.cpp
(dépôt git, binaire, moteur de l'image, précompilé, moteur téléchargé,
plan de build et conseil), la vérification git, la dernière release et
l'installation des binaires précompilés (extraction avec liens, VERSION,
ménage), la bascule fast/opt, les backends custom et leur désinstallation,
et les GPU vus par un moteur (caches mémoire et disque).

Elle détient LE job des moteurs (state/build_job + loki-build.log, repris
au démarrage). Les jobs que le Go fait encore tourner (compilation depuis
les sources, moteur de l'image, Strata) lui rapportent chaque étape par
/internal/facade/v1/build-job/* ; le Go garde le garde de l'optimiseur et
ne restaure plus de job quand la façade tient le lot.

Correction du Go : prebuiltVersionFormat lisait le format comme 3e champ
du marqueur VERSION ; hors CUDA Windows (« b300  fmt2 ») il n'y en a que
deux, et chaque vérification retéléchargeait la release au lieu de dire
« déjà à jour ». Le port suit.

Parité : parity_backends_test.go (Linux) → tests/parity_backends, rejoué
par tests/backends.rs (fausses machines dans le PATH, faux GitHub, dépôt
git reproductible) ; TestFacadeBackends pour les étapes transmises.
Mutations : 55 tuées sur 57, deux inobservables sous Linux (cudart
Windows, garde d'un avertissement).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 20:58:28 +00:00
Claude 25e31c1c26 Lot 21 : mise à jour de Loki tenue par la façade (LOKI_UPDATE=rust)
En conteneur, l'image se met à jour par Docker : /api/update rend la
version du Go sans mise à jour disponible et la note Docker,
/api/update/apply la refuse (409). La façade sert ces deux routes quand
LOKI_UPDATE=rust et LOKI_CONTAINER=1 (sysupdate.rs) ; hors conteneur,
loki-rs ne passe pas la variable et le Go garde le remplacement du
binaire.

Parité : parity_update_test.go tire du Go les quatre réponses en
conteneur, rejouées par tests/sysupdate.rs ; TestFacadeMiseAJour côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 20:01:37 +00:00
Claude 090f15615e Lot 20 : serveurs MCP tenus par la façade (LOKI_MCP=rust)
La façade tient les serveurs MCP quand LOKI_MCP=rust (expérimental ; le
Go reste le défaut) : configuration (state/mcp), catalogue embarqué (le
même fichier que le Go, copié dans l'étape Rust de l'image), état et
routes /api/mcp*. Un client MCP en Rust (mcp/client.rs) parle JSON-RPC
sur stdio et en Streamable HTTP (réponses JSON ou SSE, session suivie,
en-têtes configurés, ping du serveur servi). Le pool suit mcp_client.go :
session gardée entre les tours, connexions parallèles et partagées,
session invalidée pendant sa connexion jamais republiée, délais npx/uvx,
appel borné avec une reconnexion, sortie plafonnée à la fenêtre.

Un seul pool, celui de la façade : le Go, qui exécute encore les outils
de l'agent, lui demande les outils MCP, leurs appels et le
pré-chauffage (/internal/facade/v1/mcp/*, facade_mcp.go).

Parité : parity_mcp_test.go tire du Go les fonctions pures, seize
mutations de configuration, et un vrai serveur du SDK MCP Go (onze
outils) : ce qu'il met sur le fil et ce que le client Go en tire (état,
outils annoncés, appels). tests/mcp.rs le rejoue contre un faux serveur
qui resert ces réponses, et vérifie stdio, reconnexion, routes, catalogue
et session invalidée de bout en bout ; TestFacadeMCP côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 19:56:11 +00:00
Claude 3fc36a3acc Lot 19 : export, mode Code et critères tenus par la façade (avec LOKI_CHAT=rust)
Quand la façade tient la conversation, /api/chat/export, /api/chat/mode
et /api/chat/criteria rejoignent ses routes (chatx.rs, facadeChatRoutes
côté Go), sans nouvelle variable. L'export Markdown ou JSON part de la
conversation vivante (le Go relisait la copie enregistrée, en retard d'un
tour au besoin) : raisonnement replié, outils dans leur état final,
compaction signalée, export partiel annoncé, JSON indenté et HTML
échappé, sans mise à jour de contexte ni image en base64. Le mode et les
critères (scellés, vingt au plus, statuts ramenés) vont sur le fil de la
façade. Le reste du mode Code (indice, travaux de fond, outil criteria)
reste au Go et lit les mêmes clés.

Parité : parity_chatx_test.go tire du Go l'export d'une conversation
riche sous 22 jeux d'options et 23 appels au mode et aux critères (base
et fil après chacun, erreurs JSON imbriquées comprises), rejoués octet
pour octet par tests/chatx.rs ; tests du relais mis à jour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 19:26:27 +00:00
Claude ec55f7d3d6 Lot 18 : hub des projets tenu par la façade (LOKI_PROJECTS=rust)
La façade sert /api/projects quand LOKI_PROJECTS=rust (expérimental ; le
Go reste le défaut) : liste avec discussions, pages et trackers comptés,
création (slug translittéré et unique, index amorcé), renommage,
description, suppression (discussions, trackers et mémoire du projet,
actif réassigné, dernier projet indestructible) et bascule, refusées
pendant une génération.

Le projet actif passe toujours par le Go, qui en garde une copie en RAM.
Les discussions sont à qui tient la conversation : la façade bascule et
supprime elle-même quand elle la tient, sinon le Go garde
/internal/v1/project/switch et /internal/v1/project/drop-convs
(facade_projects.go).

Parité : parity_projects_test.go tire du Go 40 appels sur une base
amorcée (pages, discussions, trackers piégés), avec le registre, l'actif,
memory/, les trackers et l'index après chacun, rejoués par
tests/projects_api.rs contre un faux Go qui bascule et supprime comme le
vrai ; bascule et suppression par la façade de bout en bout ;
TestFacadeProjets côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 19:10:49 +00:00
MichaelandClaude Opus 5.5 4c8ac93581 Lot 6, étape 6 : la conversation tenue par la façade Rust par défaut
Sans LOKI_CHAT, c'est désormais la façade qui tient la conversation ;
LOKI_CHAT=go la rend au Go (porte de sortie, aussi listée dans le compose
Unraid). Le Go la garde de toute façon si un réglage que la façade ne
sait pas encore honorer est posé (PROJ_SNAPSHOT, KEEP_TURN_IMAGES,
NUDGE_IN_TOOL, SIDE_SLOT, SLOT_PERSIST, PREWARM, motifs retry_patterns),
décidé au démarrage comme avant.

Vérifié de bout en bout avec les vrais binaires et un faux moteur, dans
les deux modes : deux tours (texte, puis appel d'outil), même état, même
journal rejoué, discussion intacte après relance, et un panneau perf
identique (complétions, tours, pertes, sonde du gabarit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:06:27 +02:00
MichaelandClaude Opus 5.5 5edcad50a5 Conversation tenue par Rust : télémétrie et requêtes en vol comme le Go
Trois choses manquaient au tour tenu par la façade, actives même sans
clé opt-in, et qu'il fallait avant d'en faire le défaut :

- la télémétrie de chaque complétion (perfRecord) : /api/perf/summary et
  l'alerte « cache perdu » posée sur les stats finales d'une étape. La
  boucle Rust la remet au Go (/internal/v1/turn/perf) au même moment que
  la boucle Go, avec ses empreintes de messages ; le Go range, calcule la
  perte et rend nature, jetons perdus et alerte, posés sur la copie finale
  des stats ;
- la sonde du gabarit, lancée dans les mêmes conditions (requête de forme
  normale du moteur local) ;
- le comptage des requêtes en vol : chaque requête au moteur local ouvre
  un ticket (/internal/v1/oai/begin), marqué servi à la réponse 200
  (engineServed, engineMarkMain pour une étape de la discussion), rendu en
  fin de lecture du flux. Le slot de la discussion n'est ainsi ni effacé
  pendant une requête ni pris pour un autre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:04:05 +02:00
Claude 2db4cf1090 Parité moteur et système : tests réservés à Linux
parity_engine_test.go appelle lastErrorLine (sys_service_rust_linux.go)
et suppose la supervision par fichier PID du conteneur ; la façade ne
tourne qu'en conteneur Linux. La CI macOS ne compilait plus les tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 18:53:36 +00:00
Claude a4f8bd3ec4 Lot 17 : interrupteurs système tenus par la façade (LOKI_SYSTEM=rust)
La façade sert /api/internet (moteur web, serveur Crawl4AI et sa clé
jamais rendue, joignabilité avec son cache de 30 s), /api/computer
(interrupteur du navigateur, binaire trouvé, vision), /api/apikey (clé de
complétion, repli sur config.env, adresse /v1 à coller dans un client
tiers) et /api/network (écoute du moteur) quand LOKI_SYSTEM=rust
(expérimental ; le Go reste le défaut).

Restent au Go derrière son API interne (facade_system.go) : le cache de
joignabilité de ses outils web (/internal/v1/web/reach/reset), le
Chromium des outils browser_* (/internal/v1/cdp/shutdown) et le
redémarrage qui applique une clé neuve quand la façade ne pilote pas le
moteur (/internal/v1/service/action). Le relais du tunnel garde l'hôte
et X-Forwarded-Proto du client.

Correction en passant : maskAPIKey paniquait sur une clé d'un octet, et
/api/apikey tombait à chaque lecture.

Parité : parity_system_test.go tire du Go 61 appels de route avec la
base et les sondes reçues par trois faux Crawl4AI après chacun, et huit
dispositions du dossier de Playwright, rejoués par tests/system.rs ; ce
qui reste au Go vérifié de bout en bout ; TestFacadeSysteme,
TestFacadeRelaisGardeHote et TestMaskAPIKeyCourte côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 18:51:58 +00:00
Claude 2da2efc1fd Lot 16 : pilotage du moteur tenu par la façade (LOKI_ENGINE=rust)
La façade sert /api/start, /api/stop, /api/restart, /api/vram/unload et
/api/vram/reload quand LOKI_ENGINE=rust (expérimental ; le Go reste le
défaut). Elle lance elle-même « loki-rs engine <action> », le superviseur
que le Go appelait déjà en conteneur, avec l'environnement qu'il lui
passait et de quoi joindre l'API interne du Go ; sa dernière ligne
d'erreur devient l'erreur rendue. Le contrôle d'avant démarrage
(preflightEngine : preset externe, Strata, BIN, MODEL, tranches) et le
bilan de VRAM du déchargement (gpuSettle) sont portés.

La façade ne tient ce lot qu'en conteneur : sous systemd ou avec
LOKI_ENGINE_SUPERVISOR=go, loki-rs ne passe pas la variable au Go.

Restent au Go derrière son API interne (facade_engine.go) : le verrou de
l'optimiseur avant tout démarrage (/internal/v1/service/prestart), la
réponse en cours quand il tient la conversation
(/internal/v1/chat/generating) et l'arrêt de la dictée quand il la tient
(/internal/v1/dictate/stop).

Parité : parity_engine_test.go tire du Go dix bilans de VRAM, vingt-six
installations passées au préflight, les messages du superviseur et dix
réponses de route, rejoués par tests/enginectl.rs ; démarrage, arrêt,
redémarrage, déchargement chiffré et rechargement vérifiés de bout en bout
avec un faux superviseur et un faux nvidia-smi ; TestFacadeMoteur côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 18:33:31 +00:00
Claude a50b8f3cc6 Lot 15 : presets tenus par la façade (LOKI_PRESETS=rust)
La façade sert la liste des presets (pastilles, mesures du bench quand
elles correspondent à la configuration), leur ordre, l'éditeur (lecture,
graine d'un preset neuf, enregistrement avec nom de fichier unique,
prompt système), la suppression (refusée pour l'actif, modèle compris sur
demande), les aperçus de l'éditeur (cache de prompts, second slot, avis
MoE et de chargement, réutilisant le port de loki serve), la copie en
placement auto, les presets externes (la clé n'est jamais rendue, test
par un vrai appel de complétion) et la bascule, quand LOKI_PRESETS=rust
(expérimental ; le Go reste le défaut).

Ce qui touche au moteur reste au Go jusqu'à son lot : facade_presets.go
expose /internal/v1/preset/apply (bascule avec état du slot gardé et
moteur relancé ou arrêté, preset en service réappliqué) et
/internal/v1/tune/guard (verrou de l'optimiseur), et remet les routes du
tunnel à la façade.

Parité : parity_presets_test.go tire du Go les fonctions pures et 70
appels de route avec presets/, models/ et la base après chacun, rejoués
par tests/presets_api.rs ; bascule et application vérifiées de bout en
bout contre le faux Go, presetApply côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 18:13:13 +00:00
Claude 13bcbc74fe Lot 14 : ligne de commande du moteur composée en Rust (LOKI_SERVE=rust)
loki-rs engine serve fait ce que faisait loki serve : lire la
configuration (par l'API interne du Go), sonder le monde (aide du moteur,
--version, --list-devices, GGUF, RAM, VRAM, cgroup, cpuset, variables
LLAMA_ARG_*/GGML_*), composer la ligne de llama-server, vérifier le port,
garder la commande pour l'interface, puis exec. Le superviseur le lance à
la place de loki serve quand LOKI_SERVE=rust (expérimental ; le Go reste
le défaut).

serve/ porte backend_serve*.go fonction par fonction (couches GPU, --fit
et sa marge, threads, files CUDA, second slot, mode tensor, cache de
prompts, points de reprise, décodage spéculatif, fidélité, LOAD_GUARD,
SLOT_PERSIST), toute la décision restant pure. Strata, l'essai de
l'optimiseur et un lancement sans API interne passent toujours par
loki serve (Go). La façade donne au Go l'adresse de son API interne
(LOKI_GO_ADDR), héritée par le superviseur.

Parité : parity_serve_test.go tire du Go 1 872 lancements (12 machines ×
112 presets × 3 aides de moteur × 13 jeux de variables) et les sondes
pures, rejoués argument par argument par tests/serve.rs ; deux essais de
bout en bout lancent le vrai binaire contre un faux Go et un faux moteur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 17:58:24 +00:00
Claude 1e5f0904f2 Lot 13 : mémoire tenue par la façade (LOKI_MEM=rust)
La façade sert l'éditeur des pages mémoire (lecture, enregistrement,
renommage, suppression, déplacement vers un autre projet, index MEMORY.md
tenu par le code), le panneau des trackers, le mode mémoire du projet
actif et les interrupteurs du mode agent, quand LOKI_MEM=rust
(expérimental ; le Go reste le défaut).

Les pages restent des fichiers memory/<projet>/*.md écrits de façon sûre
(.bak, temporaire synchronisé puis renommé, relecture vérifiée), les
trackers des valeurs du bucket tracker. Le chiffrement reste au Go : la
façade fait ouvrir et sceller ses contenus par /internal/v1/crypt/*, et
n'écrit jamais en clair quand la mémoire est verrouillée.

gojson.rs reproduit le décodage d'encoding/json (première valeur, EOF,
erreurs du scanner et de type, structs anonymes) pour renvoyer au client
les mêmes messages que le Go ; il servira aux lots suivants.

Côté Go : facade_mem.go remet ces routes à la façade quand elles arrivent
par le tunnel ; coffre, instantanés, sauvegarde et projets restent au Go.

Parité : parity_gojson_test.go (81 corps) → tests/gojson.rs ;
parity_mem_test.go (noms, titres, slugs, modes, 36 dates, 102 appels de
route avec le disque et la base après chacun) → tests/memory.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 17:33:22 +00:00
Claude db9b0b44f5 Clippy 1.99 : as_chunks au lieu de chunks_exact(4) pour la vignette
Le clippy de la CI (Rust 1.99) refuse chunks_exact à taille constante
(chunks_exact_to_as_chunks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 17:05:47 +00:00
Claude eddcf97e3b Lot 12 : notifications Web Push tenues par la façade (LOKI_PUSH=rust)
La façade sert la clé VAPID et les (dés)abonnements, et pousse elle-même
les notifications « réponse prête » quand LOKI_PUSH=rust (expérimental ;
le Go reste le défaut) : chiffrement RFC 8291 (aes128gcm, enregistrement
complété à 4096 octets comme webpush-go), signature VAPID RFC 8292 (JWT
ES256), abonnements purgés sur 404/410, par ring déjà présent via rustls.
Mêmes clés en base que le Go.

Côté Go : facade_push.go remet les routes du tunnel à la façade, et
sendPushToAll (fin de tour, fin de tâche) lui délègue l'envoi.

Parité : parity_push_test.go tire du Go les durées et onze appels de route
avec la liste des abonnements en base, rejoués par tests/push.rs ; le
chiffrement reproduit l'exemple de la RFC 8291 à l'octet près, et un envoi
de bout en bout est déchiffré côté navigateur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 17:01:07 +00:00
Claude cbd0d5e356 Tests : scripts d'essai écrits par un sh à part (plus d'ETXTBSY)
tests/engine.rs (redemarre) a échoué en CI sur « Text file busy » : un
test écrivait son faux binaire pendant qu'un test voisin, dans un autre
thread du même process, lançait un enfant — qui héritait du descripteur
d'écriture le temps de son exec, et l'exec du script échouait. Les faux
binaires (moteur, serveur de dictée) sont désormais écrits par un sh à
part : le process des tests ne tient jamais le fichier ouvert en
écriture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 16:50:25 +00:00
Claude 8bba80dee8 Lot 11 : fichiers de la discussion tenus par la façade (LOKI_FILES=rust)
La façade sert le dépôt des pièces jointes (morceaux base64, 1 Gio au
plus, espace disque vérifié), le panneau Fichiers, la suppression, le
téléchargement (fiche, tranches base64 pour le tunnel E2E, brut avec
plage d'octets), l'affichage des images (type reniflé, CSP fermée,
vignette redressée et réduite par moyennage) et l'archive .zip, quand
LOKI_FILES=rust (expérimental ; le Go reste le défaut).

Côté Go : la nouvelle route /internal/v1/conv/workspace donne le dossier
de la discussion active (le Go la connaît toujours), facade_files.go
remet ces routes à la façade quand elles arrivent par le tunnel, et les
.part d'envois ne sont plus vidés par le Go au démarrage.

Parité : parity_files_test.go tire du Go les noms, slugs, en-têtes EXIF,
orientations et réductions (pixel pour pixel), reniflages, puis 42 appels
de route sur une arborescence fixe, rejoués par tests/convfiles.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 16:45:17 +00:00
Claude 28f90543de Lot 10 : réglages partagés tenus par la façade (LOKI_SETTINGS=rust)
La façade sert /api/prefs, /api/sysprompt, /api/reasoning-effort et
/api/oai/public quand LOKI_SETTINGS=rust (expérimental ; le Go reste le
défaut), sur les mêmes clés de la base : préférences fusionnées et
nettoyées (prénom borné, avatars en liste fermée, valeurs vides
effacées), prompt système du preset actif avec repli sur le global,
intensité du raisonnement en liste blanche, adresse publique normalisée.

Côté Go, facade_settings.go remet ces routes à la façade quand elles
arrivent par le tunnel du relais.

Parité : parity_settings_test.go tire du Go 19 adresses, 11 intensités et
29 appels de route avec le contenu de la base après chacun, rejoués par
tests/settings.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 16:25:51 +00:00
Claude 46bfab3343 Lot 9 : dictée vocale tenue par la façade (LOKI_DICTATE=rust)
La façade sert /api/transcribe et /api/dictate/* quand LOKI_DICTATE=rust
(expérimental ; le Go reste le défaut) et supervise elle-même le serveur
de transcription sherpa-onnx : lancé à la demande sur la boucle locale,
GPU masqués et sans le jeton interne, prêt dès que sa socket répond, mort
au démarrage expliquée, éteint après dix minutes d'inactivité ou quand
les réglages changent. Modèles Parakeet téléchargés et décompressés au fil
de l'eau (tar.bz2, entrées hors du dossier refusées, liens ignorés),
protocole WebSocket identique (en-tête de 8 octets, trames de 10 Kio).

Côté Go, facade_dictate.go remet ces routes à la façade quand elles
arrivent par le tunnel du relais, et asrShutdownVite (libération de la
VRAM, optimiseur) lui demande l'arrêt par l'API interne.

Parité : parity_dictate_test.go tire du Go la lecture de treize WAV, les
chemins d'archive, réglages, catalogue, arguments et treize appels de
route, rejoués par tests/dictate.rs ; supervision testée contre un faux
sherpa-onnx qui parle le protocole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 16:17:29 +00:00
Claude 17ec2291d4 Parité des modèles : conversion directe vers hfFile (staticcheck S1016)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 16:09:48 +00:00
Claude a33e5ccffe Lot 8 : toutes les routes des modèles tenues par la façade (LOKI_MODELS=rust)
La variable LOKI_DOWNLOADS devient LOKI_MODELS et couvre, en plus des
téléchargements, la liste du sélecteur (avec qui référence chaque .gguf),
la suppression (refus si en service, 409 avec les presets sans force,
tranches emportées), les dossiers de modèles et le catalogue Hugging Face
(recherche, fichiers d'un dépôt avec verdict mémoire, jeton vérifié puis
enregistré, cache vidé à son changement).

Côté Go, facade_models.go remet ces routes à la façade quand elles
arrivent par le tunnel du relais.

Parité : parity_models_test.go tire du Go les fonctions pures du catalogue
et un scénario sur disque de 20 appels (liste, dossiers, suppressions),
rejoués à l'identique par tests/models.rs ; catalogue et routes testés de
bout en bout contre un faux Hugging Face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 13:25:45 +00:00
Claude bbea5fcd3f Lot 8 : téléchargements de modèles tenus par la façade (LOKI_DOWNLOADS=rust)
La façade Rust mène les téléchargements de .gguf quand LOKI_DOWNLOADS=rust
(expérimental ; le Go reste le défaut) : sonde, lancement, suivi,
annulation, avec les mêmes contrats et la même mécanique que le Go
(tranches rapatriées en entier, plages d'octets parallèles, reprise, place
disque vérifiée sur le total, jeton réservé aux hôtes Hugging Face, refus
traduits, .part vidés au démarrage).

Côté Go : facade_downloads.go remet ces routes à la façade quand elles
arrivent par le tunnel du relais, et le Go ne vide plus les .part qu'elle
mène.

Parité : journal des fonctions pures tiré du Go (parity_dl_test.go) et
rejoué en Rust ; téléchargements de bout en bout contre un faux Hugging
Face (tests/downloads.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 13:11:07 +00:00
LogiFlow 98196e1730 Merge branch 'claude/awesome-albattani-q0do3m' : lots 6 et 7, conversation et outils tenus par la façade Rust (LOKI_CHAT=rust)
Conversation, tours, routes du chat et outils fichiers/shell/git en Rust derrière LOKI_CHAT=rust (expérimental, Go par défaut) ; boucle du contrat du mode Code ; changement de projet par la façade ; parité Go/Rust par journaux de référence (19 tours, 7 tours complets, 70 appels d'outils). Repris de la PR #25 : le sélecteur de l'en-tête ne charge que des presets.
2026-10-15 14:40:56 +02:00
Claude 57629f7254 Le sélecteur de l'en-tête ne charge que des presets (repris de la PR #25)
Le sélecteur de l'en-tête proposait, sous les presets, un groupe « Modèles
(.gguf) » listant les fichiers du disque. En choisir un écrivait MODEL= et
rien d'autre : le contexte, le NGL et l'échantillonnage du modèle
précédent restaient en place — un 27B chargé par-dessus les réglages d'un
4B partait sur 100k de contexte et mourait en OOM, sans que rien ne dise
pourquoi. Charger un modèle, c'est appliquer tous ses réglages : ça
s'appelle un preset, et c'est désormais le seul chemin.

/api/models/use disparaît avec le groupe qu'elle servait, ainsi que
switchModelFile, loadModelSwitchFiles et MS_FILES côté interface. Le title
du sélecteur le dit : « preset actif — en changer recharge tous ses
réglages et relance le moteur ».

Repris tel quel de la PR #25, dont l'historique (antérieur à la réécriture
de main) ne peut plus y être fusionné. index.html régénéré par
tools/assemble-ui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 12:35:50 +00:00
Claude 911501ac0b Parité d'un tour complet : journal indépendant de Playwright
En mode Code, le Go n'annonce web_screenshot que si Playwright est
installé : présent dans le conteneur qui avait tiré les journaux, absent
des machines de la CI, d'où deux tours de référence qui ne s'y
retrouvaient plus. Le test retire Playwright du PATH : le journal ne
dépend plus de la machine qui le tire (Linux et macOS vérifiés sur la
sortie de la CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 12:34:59 +00:00
Claude dc262a44b8 Lot 6 : parité d'un tour complet avec le Go (journal d'affichage)
internal/loki/parity_turn_test.go passe sept tours par la vraie route
/api/chat/send du Go contre un faux moteur — réponse simple, raisonnement,
outil puis réponse, « pensé sans agir », erreur du moteur, et en mode Code
un contrat déjà tenu et un contrat abandonné après deux corrections — et
range dans rust/loki-rs/tests/parity_turn/ le journal d'affichage du tour
(coalescé, mesures et horodatages masqués), l'historique rangé, les corps
envoyés au moteur, ainsi que les réponses du Go que la façade demandera
pendant ce tour (/turn/start, /turn/view, /turn/verifier). Ce que le prompt
de la machine porte de propre au lancement (dossiers temporaires,
discussion, date, hôte) est masqué des deux côtés.

tests/lot6.rs (parite_du_tour_complet) rejoue les sept tours par
chat::generate et retrouve journal, historique et requêtes à l'identique,
numérotation du journal coalescé comprise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 12:28:24 +00:00
Claude f11f667a4c Lot 7 : outils git exécutés par la façade
git_status, git_diff et git_clone (rust/loki-rs/src/tools/git.rs) quand la
façade tient la conversation : dépôt deviné comme le Go (sous-dossier
demandé, racine, unique sous-dossier dépôt), sorties mêlées et bornées,
jamais d'invite, clone limité à https:// et git@hôte: et rangé dans le
dossier de la discussion. Toujours sur cette machine, cible distante ou
non, comme le Go.

Parité : TestPariteOutilsGit (deux dépôts, l'un modifié, l'autre sans
commit ; aucun appel réseau) range tests/parity_tools/git.json ;
tests/tools_git.rs le retrouve à l'identique. Le rejeu des journaux des
outils est partagé (tests/common/toolsfx.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 12:19:07 +00:00
Claude 8fdef5455b Façade : changement de projet sans écraser la discussion, détenteur décidé par le Go
Avec LOKI_CHAT=rust, changer de projet passait par projectSwitch du Go,
qui enregistrait SA copie de la discussion — chargée au démarrage, périmée
— par-dessus celle de la façade, ouvrait une autre discussion que la façade
ignorait, et ne voyait pas un tour de la façade en cours. Désormais :

- /api/projects (switch) remet le changement à la façade
  (/internal/facade/v1/project/switch) : elle enregistre sa discussion avec
  le projet quitté, fait changer le projet actif au Go
  (/internal/v1/project/activate), ouvre la plus récente du nouveau projet
  (une neuve s'il n'en a pas) ; refusé pendant un tour (409) ;
- supprimer un projet : la façade le quitte d'abord s'il est actif, puis
  supprime ses discussions (/internal/facade/v1/project/drop).

Réglages que la façade ne sait pas honorer (PROJ_SNAPSHOT,
KEEP_TURN_IMAGES, NUDGE_IN_TOOL, SIDE_SLOT, SLOT_PERSIST, PREWARM, motifs
retry_patterns) : le Go garde la conversation, décidé une fois au démarrage
(facadeChatBlocker) ; la façade le lit (/internal/v1/chat/holder) avant de
servir quoi que ce soit, au lieu d'échouer en plein tour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 12:12:44 +00:00
Claude daa2e7e4c1 Lot 7 : boucle du contrat du mode Code tenue par la façade
Port de codeVerifyLoop (rust/loki-rs/src/chat/verify.rs) : à la fin d'un
tour de build en mode Code, relances du builder sur les critères encore
ouverts, passe de vérification sur un contexte isolé, corrections, abandon
dit dans le fil après deux corrections — mêmes deltas que le Go (role,
verify_done, mesures ctx_isolated), relances et corrections rangées dans
l'historique, vérifications non.

Le Go garde les critères (l'outil criteria s'y exécute) : la façade les lit
en base (chat/crit:<id>) et reçoit le contexte isolé du vérificateur par
POST /internal/v1/turn/verifier (prompt du rôle et faits de la machine,
outils fermés, rôle verifier, seul autorisé à marquer passed). Toute
compaction en mode Code rend l'état des critères au résumé, comme
compactMessages.

generate : vue du tour factorisée (turn_view), question à l'utilisateur
suivie (une question posée suspend la vérification).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 12:02:36 +00:00
Claude a739534104 Lot 7 : outils fichiers et shell exécutés par la façade
Quand la façade tient la conversation (LOKI_CHAT=rust), read, grep, glob,
write, edit, bash et ask s'exécutent dans rust/loki-rs/src/tools au lieu de
repartir au Go par /internal/v1/tool/exec : mêmes garde-fous dans le même
ordre (commande catastrophique, « & » final en mode Code, bac à sable du
mode Code, dossier mémoire interdit, lu avant d'écrire), même diff pour
l'UI, même nettoyage de la sortie du shell. Le jeton de l'API interne est
retiré de l'environnement du shell de l'agent.

L'état partagé reste au Go, en un seul exemplaire (sous-agents et tâches
le lisent aussi) : POST /internal/v1/tool/context (dossier de travail,
dossier mémoire, cible distante), /tool/tracker (lecture notée, contrôle
avant écriture), /tool/wrote (note après écriture, diagnostics LSP).
Repli sur le Go pour une cible distante ou un contexte indisponible.

Parité : internal/loki/parity_tools_test.go passe 58 appels à execTool sur
une arborescence de test et range le journal dans tests/parity_tools ;
tests/tools.rs le retrouve à l'identique avec exec_local. Écarts assumés
consignés dans docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 11:51:20 +00:00
Claude bc388d7a01 Lot 6 : parité de la boucle d'un tour avec le Go (journaux de référence)
internal/loki/parity_test.go rejoue 19 scénarios dans runChatTools contre
un faux moteur aux réponses scriptées, outils aux résultats fixés
(execToolHook, nil en service) : réponse simple, raisonnement, outils
seuls et en parallèle, appel répété, arguments illisibles, outil inconnu,
budget dépassé, « pensé sans agir » une et deux fois, pensée en ligne,
échantillonnage et intensité, raisonnement coupé, 500 une et deux fois,
erreur du moteur, appel d'outil écrit en texte, API externe, historique
déjà outillé. Les corps envoyés au moteur, les événements d'affichage
(texte regroupé, mesures ignorées), l'historique ajouté et l'erreur sont
rangés dans rust/loki-rs/tests/parity/*.json ; le test Go échoue si la
boucle ne les produit plus (LOKI_PARITY_UPDATE=1 pour régénérer).

rust/loki-rs/tests/parity.rs rejoue les mêmes scénarios dans
chat::turn::run_turn et retrouve les 19 journaux à l'identique.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 11:32:11 +00:00
Claude ad46bb69a6 Lot 6 : la façade reçoit le chat du Go et ses événements
Une requête qui porte le jeton interne vient du Go, qui l'a déjà
authentifiée (tunnel du relais) : les routes du chat la servent sans
redemander la clé. L'API interne de la façade, réservée à ce jeton, reçoit
les événements d'affichage nés au Go (mode Code, critères) et le
rechargement après déverrouillage : une discussion chargée vide mémoire
verrouillée revient au déverrouillage, jamais par-dessus un fil rempli
(sinon le premier message aurait écrasé la discussion chiffrée).

Le relais par la façade de /api/chat/mode et /api/chat/criteria et les
critères rendus par l'outil disparaissent : le Go pousse ses événements
lui-même, une seule source quel que soit le chemin.

README : LOKI_CHAT=rust et ce qu'il ne prend pas encore en charge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 11:19:42 +00:00
Claude 306c7a2f6c Conversation tenue par la façade : le Go lui remet ce qui lui arrive
Avec LOKI_CHAT=rust, la façade sert les routes du chat. Deux chemins
arrivent pourtant au Go sans passer par elle — le tunnel du relais et sa
redistribution /api/e2e/req — et liraient la conversation du Go, périmée,
voire l'écriraient en base par-dessus celle de la façade. facade_chat.go :
- routes du chat remises à la façade (après requireWebAuth), avec le jeton
  interne qui y tient lieu d'authentification, flux compris ;
- /api/e2e/chat lit le flux de la façade et le rechiffre événement par
  événement (nombres repris tels quels) ;
- les événements d'affichage nés ici (bascule du mode Code, critères)
  vont au journal de la façade (/internal/facade/v1/delta) ;
- l'export lit la discussion enregistrée ;
- le déverrouillage de la mémoire fait recharger la façade.

L'outil d'exécution ne rend plus les critères : critNotify les pousse lui-
même, d'où qu'il soit appelé. Sans LOKI_CHAT=rust, rien ne change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 11:19:42 +00:00
Claude 846b561efb Lot 6 : routes du chat servies par la façade (LOKI_CHAT=rust)
Avec LOKI_CHAT=rust, la façade charge la discussion active au démarrage
et sert elle-même /api/chat (flux SSE : rejeu puis direct, battement de
cœur toutes les 4 s, trames regroupées), /api/chat/send, stop, reset,
state, compact, tool-result et /api/conversations{,/new,/switch,/rename,
/delete}, avec les réponses des handlers Go. /api/chat/mode et
/api/chat/criteria restent au Go ; la façade relaie puis journalise
l'état rendu, pour que tous les appareils suivent. Pièces jointes,
fichiers, images et export restent au Go, qui suit la discussion active.

Le Go reçoit LOKI_CHAT=rust et l'adresse de la façade (étape suivante :
lui remettre le trafic de chat du tunnel). Sans la variable, rien ne
change : tout part au Go comme avant.

ChatService gagne reset (fichiers effacés par le Go), la compaction
manuelle, le résultat complet d'un outil et le relais d'un événement ;
les critères changés par l'outil criteria sont journalisés pendant le
tour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 11:11:22 +00:00
Claude e96ffe319c API interne : critères changés par un outil, fichiers seuls au reset
/internal/v1/tool/exec rend la liste des critères après un appel de
l'outil criteria qui l'a changée (add, set, clear) : critNotify la pousse
au journal du Go, une conversation tenue par la façade la reçoit ici.
/internal/v1/conv/dropped accepte files_only : Reset vide la discussion
et ses fichiers, sans la supprimer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 11:11:22 +00:00
Claude b5649eb506 Lot 6 : un tour de discussion de bout en bout en Rust (chat::generate)
Port d'EnqueueOrStart, StartTurn, generate, drainQueued,
startQueuedIfAny, dropQueued et compactAndPublish :
- envoi : réessai reconnu (cid), tour préparé par le Go (/turn/start), mis
  en file si un tour tourne, sinon moteur prêt (/health), verrou du tour
  puis bail du moteur du Go, bulle journalisée et enregistrée tout de suite ;
- tour : compaction de début de tour, vue d'envoi (tête du Go, historique
  sans <context_update>, préambule fusionné comme InjectSkills), boucle
  dont chaque événement devient un delta du journal, historique rangé
  (outils, réponse, raisonnement renvoyé, rappels en porte-à-faux retirés),
  contexte estimé si le moteur ne le compte pas, compaction de fin de tour ;
- fin : turn_done, journal coalescé, verrou et bail rendus, discussion
  enregistrée, file abandonnée après un stop ou message suivant lancé,
  sinon Web Push ;
- stop : le tour en cours, ou le détenteur Go du moteur.

chat::history porte historyFromView, dropStrayNudges, le rappel des pages
mémoire lues, l'assemblage d'InjectSkills et la forme des tool_used du
journal, avec leurs tests Go. ConvState garde ctxUsedLen et genPeak en
mémoire seulement. EngineLease::interrupted ne garde plus de verrou à
travers une attente.

Écarts : boucle de vérification du mode Code, critères pour la
compaction, préchauffage, télémétrie, retry_patterns personnalisés.

Tests de bout en bout : tour complet (outil, réponse, bail rendu,
discussion enregistrée, doublon), message en file injecté entre deux
étapes, moteur tenu par une tâche planifiée (409, stop), moteur pas prêt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 11:00:17 +00:00
Claude f2d2669690 Lot 6 : effets des discussions par le Go (GoSessionHooks)
Les outils exécutés par le Go lisent la discussion active (dossier de la
discussion, mode Code, critères) : chaque chargement, création ou bascule
la lui annonce (hook activated, /internal/v1/conv/active). Une discussion
supprimée fait nettoyer ses fichiers, travaux, critères, mode et résultats
d'outils (/internal/v1/conv/dropped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 10:48:38 +00:00
Claude ef11454a21 API interne d'un tour de discussion tenu par Rust
Ce qui décrit un tour reste au Go le temps d'être porté (lot 7) ; la
façade le demande ici :
- /turn/start : pièces jointes et vision (userMessageContent), capacités
  demandées et celles du tour (rôle du mode Code figé), nom du modèle,
  puce « mode Code » (une fois par discussion) ;
- /turn/view : la tête de turnView sans PROJ_SNAPSHOT (prompt du preset,
  consignes du dépôt, contexte du projet), le préambule d'InjectSkills et
  les outils du tour, MCP compris ; 409 sous PROJ_SNAPSHOT ;
- /turn/done : notification Web Push de fin de tour ;
- /conv/active : la discussion active des outils suit la façade ;
- /conv/dropped : fichiers, travaux, critères, mode et résultats d'outils
  d'une discussion supprimée.

injectPrefix et codeHintDue sortent d'InjectSkills et de maybeCodeHint,
sans changement de comportement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 10:48:38 +00:00
Claude fea055fd52 Lot 6 : le tour branché sur le moteur, les outils et la base (chat::host)
GoTurnHost est le TurnHost de la façade : envoi par EngineHttp (clé de
l'endpoint, pannes classées comme friendlyLLMError), outils par l'API du Go
avec leurs suites (capture, aperçu, image relayée), résultats complets
confiés à l'écrivain des discussions (id « <discussion>.<aléa> »),
compaction par Compactor avec un résumeur qui interroge le moteur
(EngineSummarizer) et l'archive recall du Go (StoreRecall), images jointes
lues sur disque et ouvertes par le Go, en cache borné à 64 Mio.

RecallArchive devient asynchrone ; la boucle consulte la mémoire des
refus de compaction avant d'annoncer une compaction en cours de tour.

Écarts : pas de résumé en continuation (état du slot tenu par le Go), ni
conseil de cache, ni garde d'écriture du mode Code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 10:34:15 +00:00
Claude e114ba3800 API interne : suites d'un outil et archive recall pour le tour tenu par Rust
/internal/v1/tool/exec rend aussi ce que la boucle Go tire du résultat
après l'exécution, et qui demande le disque ou le navigateur
(toolStepExtras) : capture de web_screenshot pour la bulle, aperçu du
navigateur après une action qui change la page (jamais après une erreur),
message qui relaie l'image au modèle (capture avec la vision, see_image).

/internal/v1/recall/archive range un bloc verbatim pour l'outil recall :
son numéro vient de la séquence bbolt, seul ce process peut le donner. 423
si la mémoire est verrouillée, rien d'écrit en clair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 10:34:14 +00:00
Claude 73bbf92f69 Lot 6 : la boucle d'un tour portée en Rust (chat::turn)
Port de runChatLoop : complétions enchaînées jusqu'à la réponse, sur les
modules déjà portés (charge utile, flux, compactage). Les filets du Go y
sont : reprises réseau et statuts passagers, flux externe coupé (rejoué ou
poursuivi après le texte affiché), intensité refusée par le gabarit,
raisonnement renvoyé refusé ou trop long, compaction réactive puis
réduction forcée, 500 (tool_choice none puis outils retirés), réponse vide
(rejeu si fenêtre pleine, relances « pensé sans agir »), appel écrit en
texte, budget souple, appels identiques court-circuités, arguments
illisibles jamais exécutés, pré-vol du mode Code, compaction en cours de
tour, file d'attente injectée entre deux étapes.

Aucune entrée-sortie dans la boucle : envoi, outils, compaction, résultats
complets et mémoires des refus passent par TurnHost ; l'arrêt par Cancel.
Non portés (clés opt-in ou machinerie du moteur local restée au Go) :
KEEP_TURN_IMAGES, PROJ_SNAPSHOT, NUDGE_IN_TOOL, SIDE_SLOT, SLOT_PERSIST,
préchauffage, sonde de gabarit, télémétrie.

15 tests sur un faux hôte aux réponses scriptées (temps suspendu pour les
reprises).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 10:22:48 +00:00
Claude bec3d2cbfd Lot 6 : petites règles de la boucle d'un tour (chat::nudges)
Port de llm_budget.go (palier AGENT_BUDGET, rappels qui reviennent à
chaque palier), appendNudge, steerSystem, dedupableTool,
repeatedCallResult et toolResultPreview : ce qui manquait à la boucle
d'un tour en plus des modules déjà portés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 10:22:48 +00:00
Claude a73b20ea04 Lot 6 : plusieurs discussions côté Rust (chat::sessions)
Port des opérations de chat_sessions.go et de LoadConversation :
discussion active créée au besoin (reprise du fil unique d'avant les
discussions, clé d'origine gardée), chargement au démarrage qui réessaie et
n'écrit rien sur une base illisible, liste du projet actif, création,
bascule (la courante enregistrée sous SON identifiant), renommage,
suppression (l'écriture en vol ne la fait pas renaître ; bascule sur la plus
récente restante ou une neuve : toujours une discussion active).

Les opérations sont sérialisées ; l'index partage son verrou avec
l'écrivain (StoreSink::index_lock, convIndexMu en Go). Fichiers, travaux,
critères, mode, résultats d'outils d'une discussion supprimée et
préchauffage après bascule passent par SessionHooks. Store gagne
get_open / put_seal (getStoreBytesErr / putStoreBytes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 10:09:00 +00:00
Claude 74c2d5105c Lot 6 : la discussion en mémoire portée en Rust (chat::conv)
Port de l'état de Conversation et de subscribeSink : état persisté et
discussion chargée, epoch (reset, bascule), verrou du tour et son arrêt,
ajout au journal avec rejet des deltas d'un tour périmé, coalescence de fin
de tour, instantané numéroté sous le même verrou, /api/chat/state.

Subscription rend les lots à émettre : rejeu (padding, reset si le curseur
vient d'une autre discussion, pagination, caught_up, contexte), puis le
direct, avec {reset} et l'en-tête paginé quand l'epoch change. Réveil par
compteur de version marqué lu avant la relecture de l'état : aucun réveil
perdu.

Tests Go de l'abonnement portés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 10:05:33 +00:00
Claude 091902039d Lot 6 : bail du moteur côté Rust (chat::lease)
EngineLease prend le verrou de génération du Go, le renouvelle au tiers de
sa durée en tâche de fond et signale au tour un stop passé par le Go ou la
perte du bail (Reset, bascule, expiration). Rendu par release, ou en tâche
de fond s'il est lâché. engine_stop arrête un détenteur Go (tâche, bench,
optimiseur). Store::internal_post garde le statut HTTP pour distinguer les
refus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 09:59:16 +00:00
Claude 27ce303d7f API interne du bail du moteur pour la conversation tenue par Rust
Le verrou de génération (conv.Generating) est partagé par le chat, les
tâches planifiées, le bench et l'optimiseur. Une conversation tenue par la
façade doit prendre ce verrou-là : sinon une tâche partirait vers le même
moteur en plein tour.

/internal/v1/engine/lease prend le verrou (409 avec la raison du refus et
la tâche en cours), /renew le prolonge et remonte un stop passé par le Go,
/release le rend (seulement s'il le tient encore), /engine/stop arrête un
détenteur Go. Sans renouvellement le bail expire : une façade tuée en plein
tour ne garde pas le moteur. Reset et les bascules de discussion rompent le
bail comme ils libèrent un tour Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 09:59:16 +00:00
Claude f15baa79aa Lot 6 : persistance des discussions portée en Rust (chat::persist)
Port de chat_persist.go et de la partie index de chat_sessions.go, au même
format en base que le Go (chat/conv:<id>, chat/index, toolres/<clé>, tous
scellés par l'API interne) :

- ConvState : JSON de Conversation à l'octet près (tranches vides en null,
  bloc projet omis), relecture tolérante comme json.Unmarshal et guérison
  stripImageParts ; instantané en plein tour avec le journal compacté ;
- index : ConvMeta, titre déduit (rappels de loki et <context_update>
  exclus, 60 caractères), tri, projets, renommage, rafraîchissement d'une
  entrée ;
- écrivain unique (Persister) : lots fusionnés, jamais un état plus ancien
  qu'un état écrit, résultats d'outils servis depuis la mémoire jusqu'à
  l'écriture, suppressions qui ne font rien renaître, file jamais bloquée
  par un écrivain qui panique ;
- StoreSink : écriture par l'API interne du Go. Mémoire verrouillée : rien
  en clair ; index illisible : jamais réécrit.

Tests Go de l'écrivain portés, plus des tests de bout en bout contre le
faux Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 08:50:51 +00:00
MichaelandClaude Opus 5.5 d8e2c3dbf6 Lot 6 : analyse du flux SSE du moteur portée en Rust (chat::stream)
La partie « protocole » de runChatLoop devient une machine à états pure et
incrémentale (StreamParser) : lignes data:, décodage strict de streamChunk
façon encoding/json (un champ mal typé saute le chunk), télémétrie tolérante
(perfWire/applyPerf), timings et usage, accumulation des tool_calls par
index, découpage </think> côté client, frappe en direct (argPreview,
bodyTail), détection des appels d'outils écrits en texte (motifs embarqués
codés à la main, fenêtre retryScanStart), pré-vol du mode Code, stats de
secours sans timings. Plus toolCallLabel, repairToolCall, l'assemblage des
appels (badArgs, ids de secours) et l'encodage json.Marshal de Go.

L'état global Go (REASONING, preset externe, REASONING_ECHO, retry_patterns,
noteEnginePrompt, codeWriteGuard, tReq) devient des paramètres explicites
(StreamConfig). HTTP, reprises, compactage et exécution restent à la boucle.

Tests Go portés un pour un, et les flux enregistrés des tests de bout en
bout rejoués sur le parseur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:07:09 +02:00
MichaelandClaude Opus 5.5 4c615d131f Compactage : politique d'écho et comptes de wire.rs plutôt qu'une copie
Le port du compactage et celui de la charge utile avaient chacun leur
version d'echoPolicy / echoTokens / echoable / hasBody. Une seule reste,
celle de wire.rs ; les 111 tests du chat passent inchangés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:01:02 +02:00
MichaelandClaude Opus 5.5 e862ac834e Lot 6 : estimation du contexte et compactage de l'historique en Rust
Port fidèle de chat_compact.go et chat_compact_cont.go dans
chat/compact.rs : estimation des jetons (raisonnement renvoyé compris),
seuils et garde de marge de génération, bornes tête/torse/queue, archivage
recall, torse dégraissé, résumé sur transcription ou en continuation,
demande en cours réinjectée, garantie de réduction de 20 %, pré-contrôle,
mémoire des refus et des échecs de la continuation, filet shrinkToFit.
Prompts et marqueurs repris à l'octet près.

L'état global Go devient explicite : CompactConfig (configuration, fenêtre,
modèle, politique d'écho), trait Summarizer (appel au moteur, tampon du
slot, appel d'outil écrit en texte), trait RecallArchive, CompactMemory,
critères du mode Code en paramètre, vue d'envoi du tour fournie par
fermeture. Tests Go portés un pour un avec un faux moteur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:00:08 +02:00
MichaelandClaude Opus 5.5 7defedf141 Lot 6 : ce qui part vers le moteur porté en Rust (chat/wire.rs)
Port fidèle du côté envoi du client de chat : wireMessages et ses étapes
(normalisation du système, contexte projet en tête du premier message
utilisateur, retrait des marques img_relay/ctx_upd, consigne de fin quand
les outils sont neutralisés), le point de sortie REASONING_ECHO
(politique, écho, comptage, mémoire des refus, erreur de gabarit), le
repli de reasoning_effort (llm_effort.go) et turnReasoning,
l'échantillonnage du preset, buildChatPayload, chatEndpoint et
completionsURL.

L'état global Go devient paramètre : configuration, clé locale, mémoire
des traductions d'intensité et des refus d'écho, verdict de la sonde de
gabarit, lecture des images rangées par référence. Aucune copie de
l'historique quand rien ne change (Cow), comme les tranches Go.

Tests Go portés un pour un (normalisation, consigne de fin, écho,
intensité, échantillonnage, endpoint, images, corps identique à
l'ancien assemblage).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:54:49 +02:00
MichaelandClaude Opus 5.5 0c1760c31a Lot 6 : journal d'affichage du chat porté en Rust (logique pure)
Port fidèle de la partie déterministe de chat_conversation.go et des trames
SSE de web_chat.go : ajout au journal avec plafond de 200 000 événements,
compaction de fin de tour (compactLog : blocs texte fusionnés avec toks, ts0
et seq0, seul le done=true des outils gardé, aperçu navigateur retiré),
rejeu coalescé (coalesceReplay, replace quand le client a vu le début du
bloc), pagination (historyCut, historyHead, 20 échanges par défaut), plan
d'abonnement (padding 2048 / 16384, reset de discussion périmée, caught_up,
ctx_used), direct par dichotomie sur le Seq (decorateEvent), et trames SSE
avec l'échappement HTML de json.Marshal et le regroupement 128 Kio / 256
événements.

Ni goroutine, ni verrou, ni base : la discussion active, le contexte utilisé
et l'horloge arrivent en paramètres, pour bâtir l'acteur de conversation
dessus. Les tests Go correspondants sont portés un pour un.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:50:17 +02:00
MichaelandClaude Opus 5.5 461ef8030d Lot 6 : clients Rust du chiffrement et de l'exécution des outils
Sceller / ouvrir un blob par le Go (mémoire verrouillée : erreur dédiée,
rien d'écrit en clair) et faire exécuter un appel d'outil par sa boucle
— les deux ponts dont la conversation Rust aura besoin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:49:56 +02:00
MichaelandClaude Opus 5.5 c38b1ac43e API interne de chiffrement pour la conversation tenue par Rust
Les discussions sont chiffrées en base quand la mémoire l'est, avec une
clé de données qui ne vit qu'en mémoire du Go après déverrouillage. Une
conversation tenue par la façade fera sceller et ouvrir ses blobs ici :
la clé ne sort jamais. Mémoire verrouillée : 423, jamais d'écriture en
clair ni de perte — l'appelant attend le déverrouillage, comme le Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:49:08 +02:00
MichaelandClaude Opus 5.5 f62271b7fe Lot 6 : transport HTTP vers le moteur en Rust
Envoi de la requête de complétion (clé de CET endpoint seulement, jamais
celle du serveur local vers une API externe), découpe du flux en lignes
comme bufio.Scanner (8 Mio au plus par ligne, arrêt net au-delà), et les
primitives de reprise de llm_retry_net.go : statuts rejouables, délai
800 ms doublé et plafonné à 5 s, classement des pannes réseau et messages
de friendlyLLMError / streamCutError à l'identique. La décision de rejouer
reste à la boucle.

HTTPS pour les presets externes : reqwest + rustls (ring), derrière une
fonctionnalité Cargo « tls » activée par défaut — le contrôle croisé Linux
sous Windows s'en passe, faute d'en-têtes libc Linux. L'étape Alpine de
l'image installe gcc pour ring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:47:53 +02:00
MichaelandClaude Opus 5.5 b2fc40d48e Lot 6 : types partagés du chat en Rust et modules réservés
Message, ToolCall, Tool, événements de flux, statistiques, journal :
sérialisation JSON identique aux types Go (omitempty, contenu any). Socle
commun des ports parallèles qui suivent (charge utile, flux, compactage,
journal).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:42:25 +02:00
MichaelandClaude Opus 5.5 c744b744ee Chat : l'exécution des outils sort de runChatLoop (execTool)
Lot 6, étape 1, sans changement de comportement : le switch des outils de
la boucle de chat (une cinquantaine d'outils) devient execTool, extrait
tel quel ; seule la question « ask », qui émettait un événement, le rend
désormais à l'appelant qui l'émet au même moment.

C'est la frontière par laquelle une boucle de chat écrite en Rust fera
exécuter ses outils par le Go tant qu'ils ne seront pas portés : API
interne /internal/v1/tool/exec, même fonction, donc mêmes garde-fous
(commande dangereuse refusée avant exécution, cible distante, écriture
hors du dossier de travail) ; la coupure de la connexion interrompt la
commande comme le bouton stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:40:59 +02:00
MichaelandClaude Opus 5.5 99e456b9a2 Spec (brouillon) du lot 6 : carte du cœur chat et stratégie de portage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:12:48 +02:00
MichaelandClaude Opus 5.5 73db4459d7 Lot 4 : /api/status, /api/service/log, /api/paths, /api/model/layers en Rust
L'en-tête de l'interface interroge /api/status en boucle : il est
désormais assemblé par la façade, à partir des aides portées (état du
service par le fichier PID, sonde /health, preset externe, cause d'un
échec de chargement lue dans le journal, pourcentage de chargement, preset
actif, alertes de volume et de FUSE). Sur une base réelle, sa réponse est
octet pour octet celle du Go.

Journal du moteur, emplacements et nombre de couches du modèle suivent.
L'état du moteur s'appuie sur la résolution des modèles de gguf.rs plutôt
que sur sa propre copie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:57:53 +02:00
MichaelandClaude Opus 5.5 d805d9f1ba Lot 4 : lecteur GGUF, couches du modèle et emplacements portés en Rust
gguf.rs reprend backend_gguf.go à l'identique : en-tête, clés et table des
tenseurs lus sous les mêmes bornes (chaînes, compteurs, profondeur des
tableaux, alignement, premier octet du dernier tenseur), v1/v2/v3 et
gros-boutiste, modèles en tranches (shardFamily porté avec), cache par
empreinte taille/date, mêmes messages d'erreur. Un panic éventuel reste
rattrapé en « GGUF illisible ». Tous les tests Go sont portés un pour un,
avec le même constructeur de GGUF synthétique.

Pour /api/model/layers : resolveModelPath / resolveServeModelPath et leurs
dossiers de modèles (models/, state/model_dirs, LOKI_MODEL_DIRS), la liste
stockée étant passée par l'appelant qui la lit via le store ; le corps JSON
de handleModelLayers est reproduit tel quel.

paths.rs sert /api/paths avec le même JSON que lokiPaths. Le « binaire en
cours » reste celui du serveur Go supervisé, passé par l'appelant, comme le
projet actif (state/active_project) et LOKI_WORKSPACE. Les équivalents de
filepath.Clean/Join/Dir/Abs/IsAbs garantissent les mêmes chaînes que le Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:53:39 +02:00
MichaelandClaude Opus 5.5 0d75e90386 Lot 4 : état du moteur porté en Rust (engstatus)
Les aides qui nourrissent /api/status et /api/service/log passent dans
loki-rs/src/engstatus.rs, à l'identique du Go :

- model_load_error_from / is_backtrace_frame (modelLoadErrorFrom) : même
  ordre des cas, même sensibilité à la casse cas par cas, libellés
  identiques à l'octet près ;
- service_log_tail : la fin du journal du moteur en conteneur ;
- engine_load_pct (Linux, -1 ailleurs) : /proc/<pid>/io et VmRSS rapportés
  à la taille du modèle, tranches comprises ; la résolution de MODEL= est
  portée localement (models/, $LOKI_MODEL_DIRS, et les dossiers de la base
  via engine_load_pct_dirs) en attendant crate::paths ;
- llm_port, is_external, health_check (sonde /health sur localhost, 3 s,
  prêt seulement sur un 200, preset externe toujours prêt).

Tests Go repris un pour un (LOAD_GUARD, Strata, OOM avant la pile
d'appels, preset externe, tranches), plus une sonde contre un serveur
hyper local : 200, 503, port fermé, connexion muette.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:53:39 +02:00
MichaelandClaude Opus 5.5 b98ee5c3e7 Lot 4 : /api/ram et /api/vram servis par Rust
RAM lue dans /proc/meminfo, jauges des cartes par un seul nvidia-smi
partagé entre tous les onglets (cache de deux secondes, une minute quand
nvidia-smi est absent), comme le Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:48:58 +02:00
MichaelandClaude Opus 5.5 dc6a001581 Lot 4 : mesures système et diagnostics de stockage portés en Rust
Le module sysinfo de loki-rs reprend, à l'identique, ce dont les routes
/api/ram, /api/vram et le bandeau de /api/status ont besoin côté Go.

La RAM se lit dans /proc/meminfo (MemTotal moins MemAvailable, en Mo) ;
hors Linux la façade rend (0, 0) et l'interface masque le bloc. Les jauges
GPU interrogent nvidia-smi, bornées à dix secondes, et passent par un cache
partagé : deux secondes de validité, une minute quand le binaire est
introuvable, et un verrou tenu pendant la requête pour que les onglets
simultanés attendent la même lecture au lieu d'en lancer chacun une. La
structure GpuStat sérialise name, used, total, util et temp, les noms que
handleVram envoie déjà.

L'alerte « /data n'est PAS un volume monté » et le conseil fuse.shfs
d'Unraid gardent leurs textes, leur règle du montage le plus long et leur
cache d'une minute. Les dossiers de modèles venant de la base, le conseil
reçoit leur lecture en fermeture asynchrone, appelée seulement quand le
cache expire.

Les tests Go de sys_storagefuse_test.go et la partie cache de
web_vram_test.go sont portés un pour un ; le test hors conteneur, qui
touche à l'environnement, vit seul dans tests/sysinfo.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:48:18 +02:00
MichaelandClaude Opus 5.5 2fcafa4ffe Presets : SHA-1 de la crate sha1 plutôt qu'une implémentation maison
Même empreinte (vecteurs officiels et empreinte du Go toujours vérifiés par
les tests), cinquante lignes de cryptographie en moins à maintenir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:48:18 +02:00
MichaelandClaude Opus 5.5 a4b0885a22 Lot 4 : la liste des presets passe à Rust (lecture seule)
Portage fidèle de ListPresets, configFingerprint et activePresetID
(backend_presets.go, web_sysprompt.go) dans loki-rs/src/presets.rs, pour
que /api/status puis GET /api/presets puissent se servir côté façade.

Le Go lisait lui-même LOKI_HOME, la configuration active et l'ordre choisi
par glisser-déposer ; ici tout arrive en paramètres : home, la config
(Store::config) et l'ordre (seau state, clé preset_order, décodé par
parse_preset_order comme le faisait getJSON). Le reste suit le Go à la
lettre : création du dossier presets, fichiers illisibles sautés, tri par
nom puis ordre stocké, clés « appareil » exclues de l'empreinte.

L'empreinte reste un SHA-1 identique octet pour octet à celle du Go, car
elle est aussi stockée ailleurs (réglage auto, résultats de tune). Faute
de crate SHA-1 disponible, une implémentation directe, vérifiée sur les
vecteurs officiels et sur une empreinte calculée côté Go.

presetCtx (pastille de contexte de /api/presets) vient avec, ainsi que
les tests Go correspondants : empreinte insensible à la mise en forme,
ordre des presets, tailles de contexte, preset actif après bascule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:47:40 +02:00
MichaelandClaude Opus 5.5 c9c9ac1ddd Lot 5 : /v1 relayé par la façade directement au moteur
La surface compatible OpenAI ne fait plus le détour par le Go : la façade
authentifie avec la clé des complétions (préflight CORS exempté), réécrit
le corps vers le second slot quand il est en service (id_slot à 1, valeurs
gardées telles quelles), retire les X-Forwarded-* et relaie au moteur sans
tampon — Host d'origine, sauf pour Strata.

Les décisions restent au Go, qui tient l'état de la conversation : la
façade lui demande où router, ouvre un ticket « en vol » avant de relayer
(refus pendant un benchmark ou une optimisation, avec Retry-After) et le
rend quand le flux se termine, que le client parte ou que le moteur soit
injoignable. Sans ticket obtenu, la requête est refusée : le Go pourrait
sinon effacer le slot en plein calcul.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:47:40 +02:00
MichaelandClaude Opus 5.5 451770bfcf Lot 5 (Go) : décisions de la surface /v1 exposées à la façade
La façade Rust va relayer /v1 directement au moteur. Les décisions restent
ici, parce qu'elles dépendent de l'état de la conversation que ce process
tient en mémoire : route (port, Strata, clé des complétions, second slot
en service), begin (requête « en vol » : le slot de la discussion n'est
pas effacé pendant ce temps, préchauffage annulé si besoin ; refus pendant
un benchmark ou une optimisation ; trace de perte de cache), end.

Mêmes étapes, même ordre qu'oaiHandler. Un ticket jamais rendu (façade
tuée en plein flux) expire au bout de six heures, sans quoi le compteur
de requêtes en vol resterait bloqué.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:45:12 +02:00
MichaelandClaude Opus 5.5 7017b66c08 Lot 4 : /api/config et /api/engine/cmdline servis par Rust
Deux routes de pilotage de plus en Rust, lues dans la base à travers le
Go. La configuration sort clés triées, comme json.Encoder le fait d'une
map Go. Faux serveur Go de test à store en mémoire, réutilisable par les
lots suivants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:43:15 +02:00
MichaelandClaude Opus 5.5 bea29bc6a9 Lot 4 (socle) : store accessible à Rust, routeur /api côté façade
Le Go expose sa base à la façade par l'API interne : lecture, écriture,
suppression d'une clé et lecture d'un bucket entier. Il reste l'unique
propriétaire du fichier bbolt ; les écritures passent par putBytes/update
et invalident son cache comme une écriture locale. Base illisible : 503,
pour que Rust distingue « rien » de « pas pu lire ».

Côté Rust : client du store (seul point de passage, que la migration
finale de la base ne touchera qu'ici), routeur /api avec contexte partagé
et garde d'authentification réutilisable ; /api/ping y emménage. Modules
réservés aux aides portées ensuite (système, état du moteur, presets,
chemins, GGUF).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:40:29 +02:00
MichaelandClaude Opus 5.5 a672760f3c Façade Rust, lot 3 : la supervision du moteur passe à loki-rs
En conteneur, start/stop/restart/status du moteur passent par
`loki-rs engine <action>`, port à l'identique de la supervision Go :
`loki serve` lancé chef de session, fichier PID et journal aux mêmes
chemins (serviceIsActive, l'UI et `loki logs` lisent les mêmes fichiers),
contrôle à 2 s avec la fin du journal en cas d'échec, arrêt de tout
l'arbre — session, groupe et filiation, pour le moteur de Strata qui prend
sa propre session — avec les mêmes délais (SIGTERM, 4 s, SIGKILL, 20 s).

Le Go garde ses garde-fous (optimisation en cours, preset externe) puis
délègue au loki-rs voisin de son binaire ; la dernière ligne d'erreur du
superviseur devient l'erreur montrée par l'UI. LOKI_ENGINE_SUPERVISOR=go
revient à l'ancienne supervision. `loki serve`, qui construit la ligne de
commande du moteur, ne change pas : il aura son propre lot.

Le contrôle « démarré ? » regarde si l'enfant est sorti plutôt que le
signal 0 : un enfant mort reste un zombie tant que son parent vit, et un
zombie répond encore au signal 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:38:37 +02:00
MichaelandClaude Opus 5.5 d4fa4488cc Façade Rust, lot 2 : interface, authentification et /api/ping
L'interface (index, marked, service worker, manifeste, polices) est
désormais servie par loki-rs, embarquée par build.rs depuis la même source
que le go:embed — les deux binaires d'un build servent la même UI. Mêmes
en-têtes de cache que le Go, gzip à la première demande puis gardé en
mémoire, jamais sur les polices ni en HEAD.

L'authentification de pilotage est portée à l'identique (requireWebAuth,
crossSiteReject, localHostName, Bearer en temps constant, mêmes messages,
mêmes codes) ; les cas de TestCrossSiteReject sont repris un pour un. La
clé est demandée à l'API interne du Go à chaque requête ; si elle ne
répond pas proprement, la façade ferme (503). /api/ping est la première
route authentifiée servie par Rust.

Le jeton de l'API interne est tiré au hasard à chaque démarrage et passé
au Go par l'environnement. La façade rend 404 sur /internal/* venu de
l'extérieur et efface X-Loki-Internal entrant avant tout relais.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:40:10 +02:00
MichaelandClaude Opus 5.5 a99d988e70 API interne pour la façade Rust : clé de pilotage à la demande
Lot 2 de la migration : la façade va authentifier elle-même ses routes
/api/*. La clé de pilotage vit dans la base bbolt, verrouillée par ce
process ; la façade la demande donc à GET /internal/v1/auth, à chaque
requête — un changement de clé prend effet tout de suite, comme avant.

La réponse contient un secret, d'où trois verrous : route branchée
uniquement sur l'écoute TCP directe (le tunnel du relais sert le mux nu),
jeton tiré au hasard par la façade à chaque démarrage et exigé en
temps constant, 404 sans rien révéler sinon. Base illisible : 503, la
façade fermera l'accès plutôt que de l'ouvrir.

Spec du lot 2 jointe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:35:00 +02:00
MichaelandClaude Opus 5.5 4b859c4eb6 Conteneur : façade Rust en frontal, binaires mis à jour au démarrage
L'image embarque loki-rs (étape Alpine, binaire musl statique) et
l'entrypoint en fait la porte d'entrée : `loki-rs serve` sur le port
public, le Go derrière lui sur 127.0.0.1:8091. LOKI_FACADE=0 revient à
l'ancien schéma si la façade pose problème.

Au démarrage, le loki-rs de l'image installe la dernière release
« rolling » dans /data/bin/<build>/ ; l'entrypoint prend ensuite loki et
loki-rs du même dossier (which-bin), et un lien dans /usr/local/sbin fait
que `docker exec loki loki …` vise lui aussi la version installée. C'est
toujours le loki-rs de l'image qui met à jour : un binaire publié
défectueux ne peut pas bloquer le correctif suivant.

CI : job Rust (fmt, clippy -D warnings, tests). Publication : la release
« rolling » part à chaque push sur main avec le même numéro de run que
l'image (LOKI_BUILD) ; IMAGE_ABI, source unique, empêche d'installer des
binaires qui exigent une image plus récente. manifest.json est envoyé en
dernier.

Message corrigé quand l'image est plus récente que la release : rien à
tirer dans ce cas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:33:18 +02:00
MichaelandClaude Opus 5.5 0a6914c936 Façade Rust loki-rs : proxy devant le Go et mise à jour au démarrage
Premier lot de la migration vers Rust. Le binaire loki-rs :

- serve : prend le port public (8090), lance `loki web 8091` en écoute
  locale et lui relaie tout — HTTP, flux SSE trame par trame, WebSocket.
  Les en-têtes de bout en bout passent intacts (Host, Authorization,
  Origin, Sec-Fetch-*, X-Forwarded-Proto : le Go en tire ses contrôles
  d'accès) ; X-Loki-Via entrant est effacé, un client ne doit jamais
  pouvoir se faire passer pour le tunnel. Go mort : relancé avec attente
  croissante, 502 lisible entre-temps.
- self-update : lit la release « rolling », installe loki et loki-rs dans
  $LOKI_HOME/bin/<build>/ après vérification SHA-256, bascule par
  réécriture atomique de release.json. Les versions s'ordonnent par numéro
  de run CI ; une ABI d'image différente n'est jamais installée. Toute
  erreur laisse l'état précédent, le démarrage n'est jamais bloqué.
  Le HTTPS passe par curl (présent dans l'image) : pas de pile TLS ici.
- which-bin : dossier d'où l'entrypoint prend les deux binaires, toujours
  ensemble.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:30:09 +02:00
MichaelandClaude Opus 5.5 342f787eff Web : adresse d'écoute réglable par LOKI_WEB_BIND
La façade Rust du conteneur prend le port public et lance `loki web`
derrière elle : le Go doit alors n'écouter qu'en local, sinon il resterait
joignable en direct sans passer par la façade. Défaut inchangé (0.0.0.0).

Plan du lot 1 de la migration Rust joint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:31:28 +02:00
MichaelandClaude Opus 5.5 f1f73cfc50 Spec : migration Rust, lot 1 — façade et mise à jour au démarrage
La réécriture en Rust se fait par lots, Rust en production dès le premier :
une façade loki-rs prend le port 8090 et proxifie vers le Go, qui passe en
écoute locale sur 8091. Chaque lot suivant lui retire des routes.

Le même lot fait que le conteneur prend la dernière version au démarrage :
release GitHub « rolling » publiée à chaque push sur main, binaires vérifiés
par SHA256SUMS et posés dans /data/bin, garde-fou d'ABI d'image, jamais de
boot bloqué.

La façade efface X-Loki-Via à l'entrée : sur l'écoute directe, l'en-tête
pouvait être forgé pour contourner le contrôle d'hôte anti-rebinding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:27:59 +02:00
LogiFlow 6ea661d705 Merge branch 'claude/bold-faraday-11i7lh' : Strata sur un SSD dédié (Unraid)
Strata sur un SSD dédié (Unraid)
2026-10-08 12:30:44 +02:00
Claude e236069c92 Strata sur un SSD dédié : place libre mesurée sur /data/strata, caches pip/HF hors de l'image
- strataDetect mesure l'espace libre de /data/strata (et non /data) : un SSD
  monté à cet endroit est celui qui compte pour l'installation.
- L'installeur reçoit XDG_CACHE_HOME=/data/strata/cache : les caches pip et
  Hugging Face ne remplissent plus /root, c.-à-d. l'image Docker d'Unraid.
- docker-compose.unraid.yml + README : monter un pool SSD en /data/strata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLbFcZj6mJjDpWvCpcChGA
2026-10-08 09:33:26 +00:00
LogiFlow 6afe07a823 Merge branch 'claude/wizardly-lamport-jfgrq5' : MTP sans recharger le modèle en brouillon
MTP : MODEL_DRAFT égal au modèle principal ignoré (poids chargés deux fois)
2026-10-08 11:18:00 +02:00
Claude 30ce3a4e86 MTP : MODEL_DRAFT égal au modèle principal ignoré (poids chargés deux fois)
Choisir le modèle lui-même comme brouillon dans l'éditeur de preset
écrivait MODEL_DRAFT=<le modèle>, et Loki passait -md vers ce fichier :
common_speculative_init_result charge alors un second exemplaire complet
du modèle, au lieu de greffer le contexte MTP sur le modèle déjà en VRAM.
Swift 27B : « allocating 9628.34 MiB on device 0 » pour le brouillon,
moteur mort ; quand ça passait, couches renvoyées sur le CPU et 5 t/s au
lieu de 22 (prefill 290 t/s au lieu de 890).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:08:07 +02:00
MichaelandClaude Opus 5.5 b7d7bfbe59 Interface : corrections de relecture du lot 2 — « Optimiser » seulement dans l'éditeur du preset en service
La ligne de l'optimiseur s'affichait dans l'éditeur de TOUT preset local, y
compris un preset neuf ou un autre que celui en service, alors que la mesure
porte toujours sur le preset actif (le README le dit) : un clic depuis
l'éditeur d'un autre preset aurait optimisé et proposé d'écrire celui en
service.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Rien ne change dans la ligne de commande du moteur.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Reprises d'AJEAN 0.15.1, 0.15.2 et 0.15.4.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:19:37 +00:00
669 changed files with 365202 additions and 1859 deletions

No files matched your search

+1
View File
@@ -8,3 +8,4 @@ dist/
!README.md
.env
.env.*
rust/target/
+18
View File
@@ -31,6 +31,24 @@ jobs:
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/loki
done
# Façade Rust (rust/loki-rs) : même exigence que le Go, zéro avertissement.
rust:
runs-on: ubuntu-latest
defaults:
run:
working-directory: rust
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust
- run: cargo fmt --all --check
- run: cargo clippy --all-targets --locked -- -D warnings
- run: cargo test --locked
macos:
runs-on: macos-latest
steps:
+54
View File
@@ -54,6 +54,7 @@ jobs:
push: true
build-args: |
LOKI_VERSION=${{ github.sha }}
LOKI_BUILD=${{ github.run_number }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Le cache de couches réutilise l'étape asrfetch (téléchargement du
@@ -61,3 +62,56 @@ jobs:
# pas : seul le code Go et l'assemblage de l'image se refont.
cache-from: type=gha
cache-to: type=gha,mode=max
# Release « rolling » : les binaires que le conteneur installe lui-même au
# démarrage (loki-rs self-update, voir docker-entrypoint.sh). Même numéro de
# run que l'image ci-dessus : c'est lui qui ordonne les versions.
# manifest.json part EN DERNIER — tant qu'il annonce l'ancien build, un
# conteneur qui démarre pendant l'envoi garde ce qu'il a ; s'il lit des
# binaires à moitié remplacés, l'empreinte ne correspond pas et il n'installe
# rien. Pré-release : `loki update` (hors conteneur, /releases/latest) ne la
# voit pas.
rolling:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: rolling-release
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust
- name: Outils musl
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools
- name: Binaires
run: |
mkdir -p dist
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags "-s -w" -o dist/loki-linux ./cmd/loki
(cd rust && cargo build --release --locked --target x86_64-unknown-linux-musl)
cp rust/target/x86_64-unknown-linux-musl/release/loki-rs dist/loki-rs-linux
file dist/loki-rs-linux
(cd dist && sha256sum loki-linux loki-rs-linux > SHA256SUMS)
abi=$(tr -d '[:space:]' < IMAGE_ABI)
printf '{"build":%s,"abi":%s,"commit":"%s"}\n' \
"${{ github.run_number }}" "$abi" "$GITHUB_SHA" > dist/manifest.json
cat dist/manifest.json dist/SHA256SUMS
- name: Publier
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view rolling >/dev/null 2>&1 || \
gh release create rolling --prerelease --target "$GITHUB_SHA" \
--title "rolling (mise à jour au démarrage)" \
--notes "Binaires installés automatiquement par le conteneur à son démarrage. Ne pas télécharger à la main."
gh release upload rolling dist/loki-linux dist/loki-rs-linux dist/SHA256SUMS --clobber
gh release upload rolling dist/manifest.json --clobber
+3
View File
@@ -22,3 +22,6 @@ Thumbs.db
.pytest_cache/
__pycache__/
node_modules/
# Rust (façade loki-rs)
rust/target/
+32 -1
View File
@@ -42,6 +42,23 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
# limité à cette étape de build.
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
# ── Étape 1 ter : façade Rust loki-rs ───────────────────────────────────
# Premier lot de la migration Go → Rust (rust/, voir docs/superpowers/specs/).
# Alpine = musl : binaire STATIQUE, indépendant de la glibc de l'image runtime —
# le même que celui publié dans la release « rolling » et installé à chaud
# dans /data/bin par `loki-rs self-update`.
FROM rust:1-alpine AS rustbuild
RUN apk add --no-cache musl-dev gcc
WORKDIR /src
# L'UI est embarquée par build.rs depuis la même source que le go:embed : les
# deux binaires d'une image servent toujours la même interface.
COPY internal/loki/ui/ internal/loki/ui/
# Catalogue MCP embarqué par loki-rs (lot 20), le même que le Go.
COPY internal/loki/mcp_catalog.json internal/loki/mcp_catalog.json
COPY rust/ rust/
RUN cd rust && cargo build --release --locked \
&& mkdir -p /out && cp target/release/loki-rs /out/loki-rs
# ── Étape 1 bis : serveur de dictée (Parakeet via sherpa-onnx) ──────────
# On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le
# changement de fond par rapport à whisper.cpp, qu'il remplace : deux étapes de
@@ -87,8 +104,12 @@ LABEL org.opencontainers.image.revision="${LOKI_VERSION}" \
# via npx ; tini en PID 1. curl et libgomp1 sont déjà dans l'image amont.
# Node 22 depuis NodeSource et non depuis Ubuntu (qui livre Node 18) : Playwright
# et la plupart des serveurs MCP réclament Node ≥ 20.
# python3 + python3-venv : l'installeur et le serveur du moteur Strata
# (backend_strata.go) sont en Python ≥ 3.10 et créent leur propre .venv dans
# /data/strata. Le moteur et le modèle ne sont PAS dans l'image : téléchargés
# à la demande depuis Paramètres → Moteur.
RUN apt-get update && apt-get install -y --no-install-recommends \
git tini ca-certificates curl gnupg \
git tini ca-certificates curl gnupg python3 python3-venv \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
@@ -127,6 +148,7 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
COPY --from=gobuild /out/loki /usr/local/bin/loki
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
COPY --from=rustbuild /out/loki-rs /usr/local/bin/loki-rs
# Dictée vocale (POST /api/transcribe). Le modèle (~500 Mo) n'est PAS dans
# l'image : téléchargé à la demande dans /data/asr/.
COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server /usr/local/bin/sherpa-onnx-offline-websocket-server
@@ -143,6 +165,15 @@ ENV LOKI_CONTAINER=1 \
NVIDIA_VISIBLE_DEVICES=all \
NVIDIA_DRIVER_CAPABILITIES=compute,utility
# Mise à jour au démarrage (loki-rs self-update) : IMAGE_ABI dit quelles
# dépendances d'image les binaires exigent — un binaire publié pour une autre
# ABI n'est jamais installé. LOKI_BUILD = numéro de run CI de cette image : un
# binaire de /data/bin n'est pris que s'il est PLUS RÉCENT. Posés ici, en fin
# d'image, pour ne pas invalider le cache des couches lourdes à chaque build.
COPY IMAGE_ABI /etc/loki/IMAGE_ABI
ARG LOKI_BUILD=0
ENV LOKI_BUILD=${LOKI_BUILD}
# 8090 : interface web (seule à exposer). Le moteur (8080) reste interne au
# conteneur — il n'est pas authentifié par défaut.
EXPOSE 8090
+1
View File
@@ -0,0 +1 @@
1
+35 -1
View File
@@ -26,7 +26,41 @@ La licence d'origine est conservée à l'identique dans [`LICENSE`](LICENSE).
(contrat builder/verifier, tracker « lu avant d'écrire », auto-retry
patterns, table languages.json) est reprise
d'**[OpenFox](https://github.com/co-l/openfox)** (MIT) et réécrite en Go
pour ce fork ; les prompts de rôles sont des réécritures originales.
pour ce fork ; les prompts de rôles sont des réécritures originales. S'y
ajoutent depuis d'autres idées reprises d'OpenFox et réimplémentées ici :
les **sous-agents** (`code_subagent.go` — un rôle délégué qui travaille dans
son propre contexte), la **vérification déclenchée quand le builder a
fini** (statut `completed`, relance sur critères ouverts), le **pré-vol**
qui refuse une écriture dès son chemin (`tool_schema_order.go`), la
**réparation des noms d'outils hallucinés** (`code_alias.go`), les
**consignes du dépôt** AGENTS.md / CLAUDE.md (`code_instructions.go`),
l'**hygiène du terminal** (`chat_shell_hygiene.go`) et un **compactage**
qui garde l'état du mode Code.
## Reprises depuis l'amont après le fork
Le fork continue de suivre AJEAN et y reprend des fonctionnalités, adaptées au
conteneur : le **contrôle du navigateur** (`computer_use.go`, `computer_cdp.go`,
`browser_grid.go` — pilotage d'un Chromium en CDP, ici celui de Playwright déjà
présent dans l'image) et la **préparation des images** envoyées au modèle
(`web_upload_orient.go` — orientation EXIF et redimensionnement), les
**notifications Web Push** (`push.go`, `web_push.go`), les **tâches script** et
le dossier de scripts (`chat_scripts.go`, `tasks_script.go`, `tasks_tools.go`),
et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
`mem_store.go`, `mem_io.go`, `mem_migrate.go`, `mem_snapshots.go`,
`backup_bundle.go`). La sauvegarde vers le relais (`relay_backup.go`) n'est
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
expose à la place un export/import de paquet chiffré en fichier local.
## Moteur Strata
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
**[Strata](https://github.com/Niko1221/Strata)** (MIT), spécialisé dans
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.0 »,
`backend_moe.go`) : Loki télécharge le paquet figé publié par AJEAN sur sa
release `moe-v1.0` (sources de Strata et binaire Linux CUDA 12 compilé, avec
les modifications d'AJEAN), vérifié par SHA-256. Ni Strata ni le modèle ne sont
inclus dans l'image : ils sont téléchargés à la demande.
## Services externes
+787 -7
View File
@@ -16,7 +16,9 @@ seule image.
```
┌────────────────── conteneur loki ──────────────────┐
│ loki web (UI + API, port 8090, premier plan) │
│ loki-rs serve (façade Rust, port 8090) │
│ │ relaie HTTP/SSE/WebSocket │
│ loki web (UI + API Go, 127.0.0.1:8091) │
│ │ pilote (fichier PID — pas de systemd) │
│ loki serve ──exec──► llama-server (CUDA, :8080) │
│ ▲ modèles .gguf │
@@ -35,6 +37,13 @@ seule image.
redémarre le moteur normalement.
- Le moteur (port 8080, non authentifié par défaut) **n'est pas exposé** ;
seule l'UI (8090) l'est.
- **Migration vers Rust en cours**, par lots : la façade `loki-rs`
(`rust/loki-rs`) prend le port public et relaie au serveur Go ; chaque lot
lui fait reprendre des routes. Déjà servis par Rust : l'interface (HTML,
JS, polices) et `/api/ping`, avec l'authentification de pilotage portée à
l'identique (la clé est demandée au Go par une API interne locale,
protégée par un jeton tiré à chaque démarrage). Plan et feuille de route :
`docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
## Démarrage rapide (Docker, GPU NVIDIA)
@@ -93,6 +102,70 @@ la recherche qu'au téléchargement. À défaut, la variable d'environnement
priorité. Un jeton en **lecture** suffit (huggingface.co/settings/tokens), et il
n'est envoyé qu'aux adresses Hugging Face.
## Choisir son moteur : llama.cpp ou Strata
Loki a deux moteurs, et le choix se fait **par preset** (clé `ENGINE`) :
- **llama.cpp** (par défaut) — n'importe quel GGUF, réglages complets dans
l'éditeur de preset.
- **[Strata](https://github.com/Niko1221/Strata)** — spécialisé dans
**Qwen3.8 Flash Next** (MoE de 125 B) : les experts sont répartis entre
VRAM, RAM et disque pour faire tourner ce modèle sur une carte de joueur.
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.0 », vérifié par
SHA-256).
**Paramètres → Moteur → Strata** : la fenêtre lit la machine (cartes, RAM,
disque), propose la version (Classique ou Swift 1.5) et la qualité qui tient,
puis installe et active le modèle en un clic (téléchargement de 66 à 84 Go dans
`/data/strata`). Le modèle installé devient un preset comme les autres :
basculer dessus lance Strata, basculer sur un autre preset revient à llama.cpp.
Son engrenage ouvre les réglages propres à Strata (contexte jusqu'à 262 144,
cache KV fp16/8 bits, prédiction MTP, lecture des images, carte d'appoint).
Pré-requis : Linux + NVIDIA RTX 20 ou plus récente (8 Go de VRAM minimum, une
deuxième carte est utilisée comme carte d'appoint), environ 46 Go de RAM pour
la plus petite qualité, pilote compatible CUDA 12.8. Le conteneur doit pouvoir
verrouiller la mémoire : `ulimits: memlock: -1` (déjà présent dans les
fichiers compose fournis). Le benchmark et l'optimiseur ne s'appliquent qu'à
llama.cpp.
### Strata sur un SSD dédié (Unraid)
Strata range **tout** dans `/data/strata` : moteur, environnement Python (~10
Go), modèle (66 à 84 Go), et, quand la RAM ne suffit pas à garder les experts
(mode « lus depuis le disque »), il les relit en continu depuis ce dossier. Un
SSD de 250 Go dédié lui suffit largement (quelle que soit la qualité choisie)
et c'est lui qui fait la vitesse en mode disque : un NVMe vaut mieux qu'un SATA,
et un disque dur de l'array est à proscrire.
1. **Le SSD en pool Unraid.** Array arrêté : *Main → Add Pool*, nom `strata`,
1 emplacement, assigne le SSD. Démarre l'array, clique le pool, *File
system type* = `xfs` (ou `btrfs`), puis *Format* en bas de la page Main.
Le SSD est alors monté en `/mnt/strata`.
(Avec le plugin *Unassigned Devices* à la place : monte-le, il apparaît en
`/mnt/disks/<nom>` — même suite en remplaçant le chemin.)
2. **Le dossier** (terminal Unraid) : `mkdir -p /mnt/strata/loki`.
Vérifie avec `df -h /mnt/strata` que c'est bien le SSD (~230 Go) et pas
`rootfs` : un `/mnt/<pool>` qui n'existe pas est dans la RAM d'Unraid.
3. **Ce qui existe déjà.** Si une installation de Strata avait commencé dans
`/mnt/user/appdata/loki/data/strata`, *Compose Down* puis, au choix :
`rsync -a /mnt/user/appdata/loki/data/strata/ /mnt/strata/loki/` (on garde,
les presets restent valides : le chemin vu par le conteneur ne change pas),
ou supprime ce dossier pour repartir de zéro. Sinon il reste caché sous le
montage et occupe de la place pour rien.
4. **Le compose** : décommente dans `docker-compose.unraid.yml` la ligne
```yaml
- /mnt/strata/loki:/data/strata
```
(sous les deux volumes existants), puis *Compose Up*.
5. **Installer** : *Paramètres → Moteur → Strata*. La fenêtre affiche
maintenant l'espace libre **du SSD** ; choisis la version et la qualité
proposées, puis installe.
Pas de partage `/mnt/user/…` pour ce montage : la couche FUSE d'Unraid
ralentit fortement la lecture des experts en mmap. Et rien à faire côté mover
ou cache : le pool `strata` n'est lié à aucun partage.
## Installation sur Unraid
L'image est construite et publiée par GitHub Actions sur GHCR
@@ -108,6 +181,32 @@ Unraid. Compose prêt à l'emploi : [`docker-compose.unraid.yml`](docker-compose
`docker-compose.unraid.yml` → **Compose Up**.
4. Interface : `http://<ip-unraid>:8090`.
## Mise à jour au démarrage
Un **simple redémarrage du conteneur** (ou du serveur Unraid) suffit à passer
à la dernière version de Loki : pas besoin de `docker compose pull` pour les
changements de code.
- À chaque push sur `main`, la CI publie la release GitHub
[`rolling`](https://github.com/R0m1k3/Loki/releases/tag/rolling) : binaires
`loki` (Go) et `loki-rs` (Rust), `SHA256SUMS`, `manifest.json`.
- Au démarrage, `loki-rs self-update` les télécharge, vérifie leur empreinte
SHA-256 et les installe dans `/data/bin/<build>/`. Le journal du conteneur
dit ce qui s'est passé (`[loki-rs update] …`, puis
`[entrypoint] binaires Loki : …`).
- **Jamais bloquant** : réseau coupé, empreinte fausse, GitHub injoignable →
les binaires déjà en place servent, le conteneur démarre normalement.
- **Quand une nouvelle image est nécessaire** (nouvelle dépendance système,
Python, Node, llama.cpp…), le fichier `IMAGE_ABI` est incrémenté : les
conteneurs plus anciens n'installent pas ces binaires et le journal demande
un `docker compose pull`.
- La mise à jour est faite par le `loki-rs` **de l'image**, jamais par celui
téléchargé : une version publiée défectueuse ne peut pas empêcher le
correctif suivant d'arriver au redémarrage d'après.
- `docker exec loki loki …` utilise aussi la version installée.
- Couper : `LOKI_AUTO_UPDATE=0`. Revenir aux binaires de l'image : supprimer
`/data/bin` et redémarrer avec `LOKI_AUTO_UPDATE=0`.
## Configuration
Tout se règle **dans l'UI** (modèle, contexte, presets…) et survit aux
@@ -122,9 +221,43 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `LOKI_HOME` | données (volume) | `/data` |
| `LOKI_MODEL_DIRS` | dossiers .gguf additionnels | `/models` |
| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint (repli : le jeton réglé dans l'UI prime) | — |
| `LOKI_CHROME` | binaire du navigateur piloté (contrôle du navigateur) ; par défaut le Chromium de Playwright de l'image | — |
| `LOKI_CU_HEADFUL` | `1` : ouvre une vraie fenêtre au lieu du mode headless (machine avec écran) | — |
| `LOKI_TRUSTED_HOSTS` | noms de domaine autorisés à servir l'UI **sans** clé de pilotage (reverse proxy), séparés par des virgules | — |
| `LOKI_AUTO_UPDATE` | `0` : pas de mise à jour des binaires au démarrage | `1` |
| `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` |
| `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` |
| `LOKI_CHAT` | qui tient la conversation : la façade Rust par défaut, outils fichiers, shell et git compris, avec l'export, le mode Code et les critères de la discussion ; le Go garde les autres outils et le reste du mode Code. La façade sonde aussi le gabarit du moteur après ses complétions (sonde de gabarit, `NUDGE_IN_TOOL`), range la télémétrie de ses complétions et ses requêtes en vol auprès du Go, honore les motifs de relance personnalisés (`retry_patterns`) et les slots du moteur (`SIDE_SLOT`, `SLOT_PERSIST`, isolation après la vérification) les images gardées (`KEEP_TURN_IMAGES`), le préchauffage (`PREWARM`) et le bloc projet figé (`PROJ_SNAPSHOT`). `go` : la rend au Go (porte de sortie) | Rust |
| `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_FILES` | `rust` : les fichiers de la discussion — dépôt, panneau Fichiers, archive .zip, téléchargement, images et vignettes — sont servis par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_PUSH` | `rust` : les notifications Web Push (clé VAPID, abonnements, envoi « réponse prête ») sont tenues par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent), et les outils mémoire de l'agent (mem_search, mem_read, mem_add, mem_edit, tracker) dans les tours de la façade, sont tenus par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go |
| `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go |
| `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go |
| `LOKI_UPDATE` | `rust` : la mise à jour de Loki (`/api/update`, `/api/update/apply`) est servie par la façade Rust ; en conteneur, l'image se met à jour par Docker (expérimental, migration en cours) | Go |
| `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels ; les tours de la façade appellent son pool directement (expérimental, migration en cours) | Go |
| `LOKI_BACKENDS` | `rust` : les backends llama.cpp — statut, vérification git, binaires précompilés, bascule fast/opt, backends custom, GPU vus par un moteur — et le job des moteurs sont tenus par la façade Rust, qui compile aussi llama.cpp depuis les sources sous Linux et macOS ; les jobs du Go (compilation sous Windows, moteur de l'image, Strata) lui rapportent leurs étapes (expérimental, migration en cours) | Go |
| `LOKI_STRATA` | `rust` (avec `LOKI_BACKENDS=rust`) : l'écran de Strata — machine, ce qui y tient, recommandation, modèles installés —, ses réglages et son installation sont tenus par la façade Rust ; le lancement reste au Go (expérimental, migration en cours) | Go |
| `LOKI_TASKS` | `rust` : les tâches planifiées — liste, enregistrement, bascule, interrupteur maître, lancement, arrêt —, le planificateur et les tâches « script seul » sont tenus par la façade Rust ; les outils task_* de l'agent aussi, dans les tours de la façade ; une tâche IA reste exécutée par le Go, à la demande de la façade (expérimental, migration en cours) | Go |
| `LOKI_BENCH` | `rust` : le banc d'essai — lancement, progression, annulation, dernière mesure, mode rapide et mode complet en profondeur — est tenu par la façade Rust ; le verrou de génération reste celui du Go, pris par le bail du moteur (expérimental, migration en cours) | Go |
| `LOKI_TUNE` | `rust` : l'optimiseur — lancement, essais, progression, annulation, application dans une copie ou dans le preset, dernier résultat — est tenu par la façade Rust, sous Unix ; le verrou reste le fichier `tune.lock` lu par le Go, les essais des « loki serve » du Go (expérimental, migration en cours) | Go |
| `LOKI_ENGINE_IMAGE` | `rust` (avec `LOKI_BACKENDS=rust`) : le panneau « moteur de l'image » — version qui tourne, dernier build publié, version recommandée, mise à jour depuis le registre officiel avec essai à blanc, bascules, retour à la version précédente, vérification du rendu du gabarit — est tenu par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go |
| `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust (expérimental, migration en cours) ; le navigateur piloté, lui, est celui de la façade dès qu'elle tient la conversation | Go |
| `LOKI_ENGINE` | `rust` : le pilotage du moteur (démarrage, arrêt, redémarrage, déchargement et rechargement de la VRAM) est tenu par la façade Rust, en conteneur avec son superviseur ; le verrou de l'optimiseur reste au Go (expérimental, migration en cours) | Go |
**Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les
hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse
toute requête venue d'un autre site : une page web ouverte dans un navigateur
du réseau ne peut plus piloter Loki en douce (ni par DNS rebinding). Derrière
un reverse proxy (`loki.mondomaine.fr`), définir une clé
(`docker exec -it loki loki set-web-key`) ou lister le nom dans
`LOKI_TRUSTED_HOSTS`.
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
`logs`, `restart`, `config`, `bench`, `test`…).
`logs`, `restart`, `config`, `bench`, `tune`, `test`…).
### Données et persistance
@@ -137,7 +270,9 @@ et modèles compris.
| `/data/loki.db` | base bbolt : préférences, discussions, mémoire des réglages |
| `/data/presets/` | un `.env` par preset (modèle, contexte, NGL, vision…) |
| `/data/models/` | modèles téléchargés depuis l'interface |
| `/data/memory/` | pages de mémoire persistante (`.md`) |
| `/data/memory/` | pages de mémoire persistante (`.md`) — joignables **uniquement** par les outils `mem_*`, pas au shell ; chiffrées si le chiffrement est actif |
| `/data/backups/memory/` | snapshots de la mémoire pris avant chaque opération qui touche à tout |
| `/data/scripts/` | scripts durables de l'agent, hors du workspace jetable (planifiables sans modèle) |
| `/data/workspace/` | racine du dossier de travail de l'agent |
| `/data/workspace/discussions/<id>/` | fichiers d'UNE discussion : dépôts, captures, ce que l'agent y écrit |
| `/data/loki-engine.log` | journal de `llama-server` (aussi via `loki logs`) |
@@ -154,6 +289,27 @@ Sur Unraid, garde le **même** chemin hôte d'un lancement à l'autre : `/mnt/us
emplacements différents dès que le partage n'est pas en cache-only ou que le
*mover* est passé. Le compose fourni utilise `/mnt/user/appdata/loki/…`.
**Performance sur Unraid.** `/mnt/user/…` passe par `shfs`, la couche FUSE des
partages : chaque écriture de `loki.db` et chaque page d'un gros modèle relue
depuis le disque (MoE plus gros que la RAM, mmappé) traverse un démon en espace
utilisateur, ce qui ralentit le décodage. Loki le détecte au démarrage
(`fuse.shfs` dans `/proc/self/mountinfo`) et l'affiche en encart d'information —
rien n'est perdu ni altéré. Pour l'éviter :
- **recommandé** — partages en *Exclusive access* (Unraid 6.12+). Ce n'est
pas une case mais un état : *Global Share Settings* → *Permit exclusive
shares* = Yes, puis le partage `appdata` (et celui des modèles) entièrement
sur **un** pool — mover d'abord vers le pool, ensuite *Secondary storage* =
None — jusqu'à lire *Exclusive access : Yes* sur la page du partage.
Redémarre le conteneur : le chemin `/mnt/user/…` ne change pas, FUSE est
court-circuité ;
- **avancé** — mappe `/mnt/<ton-pool>/appdata/loki/…` (ex. `/mnt/cache` si ton
pool s'appelle `cache`), avec une migration manuelle : `Compose Down`,
`rsync -a` de l'ancien dossier vers le nouveau, puis modification des deux
lignes `volumes`. Un `/mnt/<pool>` inexistant atterrit dans la RAM d'Unraid
(données perdues au redémarrage, serveur saturé par un modèle de 80 Go), et
changer le chemin sans migrer donne un `/data` vide.
**Tu perds modèles, discussions et fichiers à chaque redémarrage ?** C'est le
signe que `/data` n'est **pas monté** : le conteneur écrit alors dans sa couche
éphémère, détruite à chaque recréation (mise à jour d'image, `compose down`,
@@ -175,11 +331,29 @@ Héritées d'AJEAN :
- **Tchat** avec streaming, raisonnement visible, pièces jointes, export de
conversations. La **vision** demande un modèle multimodal *et* son projecteur
`mmproj` — voir [Installer un modèle](#installer-un-modèle).
`mmproj` — voir [Installer un modèle](#installer-un-modèle). Toute image
envoyée au modèle (pièce jointe, `see_image`, capture) est d'abord
**redressée** — l'orientation EXIF d'une photo de téléphone est cuite dans
les pixels, sinon le projecteur, qui ignore l'EXIF, la voyait couchée — et
**ramenée sous 1568 px** de grand côté, taille au-delà de laquelle le base64
grossit sans rien apprendre de plus au modèle.
- **Mémoire persistante** (`memory off|ondemand|always`).
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
- **Agent** : shell, fichiers, workspace (`agent on`).
- **Contrôle du navigateur** (`computer on`, ou *Réglages → Contrôle du
navigateur*) : l'IA **pilote** un Chromium — celui de Playwright, déjà dans
l'image — par le protocole DevTools. Elle ouvre une page, en reçoit les
éléments interactifs **numérotés** (`[12] bouton « Se connecter »`) et agit
par numéro : `browser_open`, `browser_snapshot`, `browser_find`,
`browser_click`, `browser_type`, `browser_key`, `browser_scroll`. Aucune
vision requise — ça marche avec de petits modèles texte. Avec un projecteur
`mmproj` chargé s'ajoutent `browser_screenshot` (image quadrillée tous les
100 px) et `browser_click_xy`, pour ce que l'arbre d'accessibilité ne montre
pas (canvas, bandeau en iframe). Ce sont des **actions réelles** sur le web :
l'interrupteur est distinct de l'accès internet et n'agit qu'en **mode
agent**, au même niveau de confiance que `bash`. La session navigateur est
unique et réutilisée entre les appels ; couper l'interrupteur la ferme.
- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour
les serveurs écrits en JavaScript comme en Python. Au **premier** lancement,
`npx`/`uvx` téléchargent le paquet du serveur — Loki attend jusqu'à 3 minutes
@@ -195,7 +369,48 @@ Héritées d'AJEAN :
activer avant l'exécution, accès mémoire et web. Un **interrupteur maître**
suspend tout d'un coup. Sans **mode agent**, une tâche s'exécute mais n'a aucun
outil pour agir — l'interface le dit. Une seule inférence tourne à la fois : une
tâche attend son tour, et le bouton stop du chat l'interrompt.
tâche attend son tour, et le bouton stop du chat l'interrompt. Deux types de
tâche : une **consigne IA**, ou un **script seul** — un fichier du dossier
`/data/scripts` lancé directement, **sans charger le modèle ni consommer un
token** (sauvegarde, synchro, nettoyage n'ont rien à demander à un LLM). Ce
dossier vit **hors du workspace** : vider une discussion, ou la supprimer,
n'y touche pas. L'IA elle-même dispose des outils `task_create`, `task_list`,
`task_update` et `task_delete` — elle peut donc se poser ses propres rappels
et veilles — cloisonnés par projet : dans un projet, elle ne voit et ne
pilote que les tâches de ce projet.
- **Notifications** (Web Push) : le serveur prévient le navigateur **à la fin
d'une réponse et à la fin d'une tâche planifiée**, même l'app fermée ou le
téléphone verrouillé — c'est le serveur qui pousse, pas la page (un onglet
caché relâche son flux). L'interrupteur est dans *Réglages → Mode agent*, à
armer **sur chaque appareil** (l'abonnement appartient au navigateur). Exige
**HTTPS** ou localhost ; sur iPhone, il faut d'abord ajouter Loki à l'écran
d'accueil. Les clés VAPID sont générées à la première demande et rangées avec
le reste sous `/data` ; le corps de la notification reste générique (aucun
extrait de réponse), puisqu'elle transite par Apple ou Google.
- **Chiffrement de la mémoire** (*Réglages → Mémoire*) : pages mémoire,
discussions, blocs archivés au compactage et trackers chiffrés en
**AES-256-GCM** sur le disque. Chiffrement à enveloppe : une clé de données
(DEK) tirée une fois, enfermée dans un coffre par une clé dérivée en
**Argon2id**. Ce qui ouvre le coffre : la **clé de pilotage de l'appareil**
(le serveur n'en garde qu'une empreinte — il ne peut pas ouvrir le coffre
seul) ou une **clé de récupération** affichée une seule fois à l'activation.
La DEK ne vit qu'en RAM : après un redémarrage à froid, la mémoire est
verrouillée jusqu'à ce qu'un navigateur se reconnecte — verrouillée, Loki
n'écrit jamais de clair par-dessus du chiffré, il refuse d'écrire. Règle
tenue partout : **rien n'est supprimé avant que son remplaçant ait été relu
et vérifié**, un **snapshot** est pris avant chaque bascule, et une migration
interrompue reprend au démarrage.
- **Sauvegarde chiffrée en fichier** : *exporter* télécharge un paquet scellé
(mémoire, presets, réglages) que *importer* rejoue sur un autre serveur avec
la seule clé — de quoi remonter le conteneur ailleurs. C'est la sauvegarde de
l'amont sans son relais : ici rien ne part sur un service tiers, le fichier
reste chez toi.
- **Interface en anglais** (*Réglages → Apparence → Langue*) : la source reste
française et « English » applique un dictionnaire sur la coque — navigation,
intitulés, boutons, interrupteurs. Ce qui n'y figure pas **reste en
français** plutôt que d'afficher une clé technique, et le fil de discussion
n'est jamais touché : c'est ton contenu. Le dictionnaire s'enrichit sans
toucher au reste de l'interface (`ui/src/js/28-i18n.js`).
- **Presets** de configuration par modèle, bench, auto-détection GPU.
- **Échantillonnage réglable par preset** : température, `top_p`, `top_k`,
`min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces
@@ -228,7 +443,15 @@ Ajoutées par ce fork :
de code fait apparaître une puce « passer en mode Code ? » — suggestion,
jamais bascule automatique. Conception reprise
d'[OpenFox](https://github.com/co-l/openfox) (MIT), réécrite en Go — voir
`NOTICE.md`.
`NOTICE.md`. **Sous-agents** : l'outil `subagent` délègue une recherche
(`explorer`), une relecture (`code-reviewer`) ou un découpage (`planner`) à
un rôle qui travaille dans **son propre contexte** et ne rend que sa réponse.
Sur un modèle local, c'est ce qui sauve la fenêtre : « trouve où est géré le
cache » coûte dix lectures de fichiers, qui resteraient sinon dans
l'historique jusqu'à la compaction alors que seule la réponse comptait. Tous
les rôles délégués sont en **lecture seule** — ce qui modifie le dépôt reste
dans le fil principal, sous tes yeux — et un sous-agent ne peut pas en
appeler un autre.
- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** —
une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite,
playwright, context7, github…) avec leur commande déjà renseignée, classés par
@@ -255,6 +478,9 @@ Ajoutées par ce fork :
→ maximale) et rejoue le message sans rien perdre de l'historique — une fois,
puis la traduction est retenue pour ce modèle. La liste est grisée quand le
raisonnement est coupé pour ce modèle.
- **Performance** : cache de prompts, slots, décodage spéculatif, placement
MoE et multi-GPU, optimiseur — tout est rassemblé dans la section
[Performance](#performance), avec la table des clés et leur défaut.
- **Discussions multiples** : historique complet dans la barre latérale, titre
repris du premier message (renommable), suppression. **Chaque discussion a son
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
@@ -360,6 +586,526 @@ Retirées par ce fork :
sa route n'avait aucun consommateur et son repli embarqué datait de 2024. La
recherche Hugging Face le remplace.
## Performance
Le but : des tours plus courts **sans dénaturer le modèle**. Rien ici ne
touche à la quantification des poids, à l'échantillonnage, à la température, au
budget de raisonnement ni au contenu du contexte. Tout ce qui changerait ce
qu'envoie Loki au modèle, le placement des poids ou les slots du moteur est
**désactivé par défaut**, derrière une clé ; sans clé, la requête et la ligne de
commande restent celles d'avant. Les clés se posent dans le preset (éditeur) ou
par `loki config set CLÉ valeur` ; celles marquées « moteur relancé » demandent
un redémarrage du moteur.
### Actif par défaut, sans perte
- **Cache de prompts dimensionné** (`--cache-ram`) : agrandi pour un modèle
tout-GPU d'après l'état mesuré dans le GGUF, jamais sous le défaut du moteur ;
et le slot est effacé après un sous-agent, une vérification, une tâche ou un
bench, pour que la conversation revienne du cache RAM au lieu d'être
recalculée.
- **Préfixe stable** : rappels de Loki gardés dans l'historique tels
qu'envoyés, relance après un 500 sans toucher aux outils ni au système, ligne
MCP présente dès le premier tour, ordre des trackers fixe.
- **Comptage du contexte** sans le raisonnement jamais renvoyé : la compaction
(avec perte) part moins tôt, et son résumé n'est plus amputé de l'état
d'avancement.
- **Threads CPU** : sans `THREADS`, le moteur prend ses cœurs physiques (plus
tous les threads logiques) ; conteneur à l'étroit (cpuset, quota) borné.
- **Deux GPU** : `CUDA_SCALE_LAUNCH_QUEUES=4x` quand le pipeline entre cartes
est possible (même calcul, seul le prompt peut gagner).
- **Points de reprise des hybrides** sur un moteur officiel antérieur à
`b10864` : `--checkpoint-min-step 2048` d'office si la RAM le permet, et un
avis « moteur trop ancien » ; l'encart « version recommandée » propose la mise
à jour (sur clic seulement, voir [Mettre à jour llama.cpp](#mettre-à-jour-llamacpp-sans-reconstruire-limage)).
- **Côté Go** : écriture d'un gros fichier en temps linéaire, persistance de la
discussion hors du chemin du premier jeton, un seul `nvidia-smi` partagé
(moniteur et éditeur de preset), stockage Unraid servi par FUSE signalé.
- **Mesure** : télémétrie par complétion (cache repris, recalculé, brouillon
accepté) dans l'interface et `GET /api/perf/summary`, sonde du gabarit de
chat (lecture seule, `/apply-template`), bench honnête en tâche de fond.
- **Garde-fous** : un cache KV quantifié, `--context-shift` ou `--cache-reuse`
posés à la main sont signalés ; un mode de chargement résident voué à
l'échec est refusé (`LOAD_GUARD`) ; avis de placement MoE dans l'éditeur.
### Les clés
| Clé | Défaut | Effet | Prix, zone grise |
|---|---|---|---|
| `CACHE_RAM` | auto | cache de prompts en RAM hôte (`--cache-ram`, Mio), copie exacte des conversations quittées | `-1` = RAM non bornée (déconseillé avec un MoE mmappé), `0` = coupé |
| `CACHE_ISOLATE` | actif | efface le slot après un travail annexe (sous-agent, vérification, tâche, bench) | `off` pour couper ; sans objet avec `SIDE_SLOT` |
| `THREADS` / `THREADS_BATCH` | vide = cœurs physiques | `-t` / `-tb` | — |
| `CUDA_LAUNCH_QUEUES` | auto (4x sur ≥ 2 GPU si pipeline) | file de lancements CUDA | `off`, ou `0.25x`…`4x` imposé ; réglage de machine |
| `CTX_CHECKPOINTS` | moteur (32) | points de reprise par slot d'un hybride | 70 à 200 Mio de RAM hôte chacun |
| `CKPT_MIN_STEP` | moteur (2048 d'office : hybride, moteur officiel < `b10864`) | espacement minimal des points de reprise | — |
| `LOAD_GUARD` | actif | refuse un `--load-mode` résident qui ne tient pas en RAM | `off` = lancer quand même |
| `REASONING_ECHO` | off | renvoie au moteur local la réflexion du modèle, au format entraîné | plus de contexte par tour, compaction plus tôt |
| `REASONING_PRESERVE` | vide = défaut du moteur | `--reasoning-preserve` / `--no-reasoning-preserve` | change le rendu : `off` rend l'ancien historique à Qwen3.6, mais change celui de Qwen3.8 |
| `PROJ_SNAPSHOT` | off | bloc projet figé par discussion, changements en `<context_update>` ; date et dossier sortent du système | le modèle lit les changements en tête du message suivant |
| `PREWARM` | off | `on` : prépare le prochain tour pendant que tu lis ; `full` : aussi la discussion qu'on ouvre | une requête de plus au moteur au repos (1 jeton, jeté) |
| `COMPACT_CONTINUATION` | off | le résumé de compaction prolonge le prompt en cache au lieu d'une transcription à froid | repli sur l'ancien chemin au moindre écart |
| `KEEP_TURN_IMAGES` | off | garde dans l'historique les images montrées par les outils | **zone grise** : plus de contexte, compaction plus tôt ; une API externe refacture les images |
| `NUDGE_IN_TOOL` | off | le rappel de budget d'outils part au bout du dernier résultat d'outil | **zone grise** : un modèle peut moins bien suivre une consigne lue dans un outil |
| `SIDE_SLOT` | off (moteur relancé) | second slot pour les travaux annexes, la discussion garde le sien | cache KV et état récurrent en double en VRAM |
| `SLOT_PERSIST` | off | état du slot gardé sur disque à la bascule de preset, rechargé au retour | 2 fichiers de 8 Gio au plus ; refusé avec `SPEC` |
| `SPEC` | off (moteur relancé) | décodage spéculatif : `auto`, `mtp`, `ngram`, `mtp+ngram` | même distribution, pas le même texte au bit près ; MTP : 1 à 2 Go de VRAM |
| `MODEL_DRAFT` | — | tête MTP publiée à part ou petit modèle brouillon | VRAM |
| `SPEC_N_MAX` | moteur (3) | jetons anticipés par étape | — |
| `SPEC_SAMPLING` | greedy (exact) | `probabilistic` seulement avec `SPEC=mtp` ou `mtp+ngram` | — |
| `SPLIT_MODE` | couches | `tensor` : parallélisme de tenseurs entre cartes CUDA | expérimental : décodage plus rapide, prefill plus lent |
| `FIT_TARGET` | 1024 Mio par carte | marge laissée libre par le placement auto (`--fit-target`) | — |
| `OP_OFFLOAD_MIN_BATCH` | moteur (32) | lot à partir duquel les experts MoE sur CPU sont recopiés vers le GPU | à mesurer |
| `CUDA_GRAPH_OPT` | off | branches Q/K/V en parallèle au décodage | expérimental ; `off` puis redémarrage s'il plante |
| `KV_TYPE` (`_K`, `_V`) | f16 | quantification du cache KV | **zone grise** : `q8_0` modifie légèrement les sorties, `q4_0` perte mesurable — jamais posé par Loki |
| `BATCH` / `UBATCH` | 2048 / 512 | lots du prefill | MoE aux experts en RAM : `UBATCH` 2048+ |
| `LOKI_PERF_LOG` (environnement) | absent | une ligne `[perf]` par complétion sur stderr | — |
Outils sans clé, qui ne font rien d'eux-mêmes :
| Outil | Ce qu'il fait |
|---|---|
| **Bench** (bouton, `loki bench`, `--full`) | mesure en tâche de fond, à profondeur réelle avec le mode complet (prefill à froid, tours qui reprennent le cache, decode) |
| **Optimiseur** (`loki tune`, bouton « Optimiser… ») | cherche lots, threads, marges, placement (`--placement`) et options opt-in (`--opt-in`) sur un moteur d'essai ; rien n'est écrit sans un clic |
| **Dupliquer en placement auto…** (éditeur, MoE) | copie du preset où `--fit` place les experts au lieu de `-ot` / `--n-cpu-moe` |
| **Version recommandée** (panneau Moteur) | met à jour llama.cpp vers un build ≥ `b10864`, sur clic, avec retour à la version précédente |
### Ordre de test conseillé
Une étape à la fois, mesurée avant la suivante :
1. **Bench complet** du preset tel quel : la référence.
2. **`PREWARM=on` et `COMPACT_CONTINUATION=on`**, puis une vraie session de
travail ; `GET /api/perf/summary` dit, par nature de requête
(`kinds.main`…), combien de fois et de jetons le cache a été perdu
(`lost_events`, `lost_tokens`) et le prefill par tour.
3. **Optimiseur** (« Optimiser… ») sur le preset en service : lots, threads,
marges.
4. **MoE aux experts en RAM** : « Dupliquer en placement auto… », bascule sur
la copie, `successfully fit params` au journal, bench complet des deux.
5. **Mise à jour du moteur** vers la version recommandée si l'encart s'affiche
(points de reprise des hybrides, MTP rapide) ; vérifier le rendu du gabarit
dans le panneau.
6. **`SPEC=auto`** (ou `mtp`) si le modèle a une tête MTP : bench, puis
`draft_rate` dans `/api/perf/summary`.
### Ce qui a été écarté, et pourquoi
- **`--cache-reuse`** : réutilise des morceaux de cache calculés sous un autre
contexte, donc change les sorties ; llama.cpp le coupe d'ailleurs pour les
hybrides et le multimodal. Signalé s'il est posé à la main.
- **`--context-shift`** à la place de la compaction : retire des jetons du
contexte (perte), et ne marche pas sur les hybrides. Signalé lui aussi.
- **Cache KV quantifié par défaut** : `q8_0` n'est pas exact, `q4_0` perd
mesurablement, pour un gain d'environ 2 Gio sur un 27B hybride (16 couches
sur 64 portent du KV). Reste un choix explicite et étiqueté (`KV_TYPE`).
- Aussi : `-sm tensor` par défaut (prefill plus lent, expérimental), plus de
slots par défaut (OOM : états récurrents par slot), `--cache-ram -1` (RAM
non bornée), et tout réglage d'échantillonnage ou de quantification « pour
aller plus vite ».
### Détail des clés et des outils
- **Bench** (bouton du preset, `loki bench`, `--full` pour le mode complet) :
en tâche de fond (`POST /api/bench`, progression et annulation), chat,
tâches et compaction refusés le temps de la mesure. Le mode rapide mesure une
ligne courte avec le gabarit, le raisonnement et l'échantillonnage du preset ;
le mode complet ajoute un prefill à froid à profondeur réelle (jusqu'à 32 k
jetons, 16 k si des poids tournent sur CPU) et trois tours qui reprennent le
cache — reprise lue dans `cache_n`, jamais supposée. Rien n'est enregistré
sans réponses valides et timings réels ; l'empreinte du preset accompagne
chaque mesure.
- **Raisonnement renvoyé au modèle** (clé `REASONING_ECHO`, **off** par défaut,
`loki config set REASONING_ECHO on`) : le raisonnement que le moteur local a
séparé (`reasoning_content`) est gardé avec chaque message et renvoyé au même
modèle. Les gabarits Qwen3.5/3.6 relisent alors les étapes d'une boucle
d'outils avec leur réflexion — le format entraîné — au lieu de blocs vides, et
le moteur ne recalcule plus le dernier message. Avec `REASONING_PRESERVE=on`
(gabarits qui ont ce réglage, Qwen3.6), le préfixe reste stable d'un message
utilisateur à l'autre ; Qwen3.5 n'en a pas, le gain y reste interne au tour.
Contrepartie : plus de contexte par tour, donc compaction plus tôt. Jamais
vers une API externe ni vers un autre modèle ; un prompt trop long est d'abord
rejoué sans raisonnement, et un gabarit qui le refuse le suspend pour ce
modèle jusqu'au redémarrage.
- **Bloc projet figé** (clé `PROJ_SNAPSHOT`, **off** par défaut,
`loki config set PROJ_SNAPSHOT on`) : le contexte du projet (description,
index mémoire, trackers, `AGENTS.md`) part en tête du premier message ; sans
la clé il est reconstruit à chaque tour, et une page créée ou une valeur de
tracker notée fait recalculer toute la conversation derrière lui. Avec la clé,
chaque discussion garde une copie datée du bloc, envoyée à l'identique, et les
changements arrivent en tête du message suivant dans un bloc
`<context_update>` : pages ajoutées, modifiées, retirées, nouvelle ligne d'un
tracker, et le texte **complet** d'une description ou d'un `AGENTS.md` modifié
— rien n'est perdu, seul ce petit bloc est à calculer. Une ligne du prompt
système (présente seulement avec la clé) dit au modèle que ces blocs viennent
de Loki et que le plus récent l'emporte. La date du jour et le dossier de
travail de la discussion (ou, pour un poste distant ciblé, son nom, son
dossier et s'il est hors ligne) quittent aussi le prompt système pour ce
bloc, les consignes restant dans le système : système et outils deviennent
les mêmes d'une discussion à l'autre, et passer minuit n'ajoute qu'une ligne
de mise à jour au lieu de tout recalculer. Le bloc est repris tout neuf, et les
anciens `<context_update>` retirés, quand le début du prompt change de toute
façon : compaction, système ou outils modifiés (réglages, mode), redémarrage de
Loki, changement de modèle, de preset, de projet, de mode mémoire ou de mode
Code — et dès que les mises à jour accumulées deviennent trop longues. Les
tâches planifiées gardent le bloc à jour à chaque tour et, comme les
sous-agents, la vérification et le terminal, leur système d'avant, date et
dossier compris ; sans agent, rien du
projet n'est envoyé ; un preset externe (API) n'est jamais concerné. Le titre, l'export JSON, le résumé de compaction et la
passe de vérification ne voient pas ces blocs.
- **Préchauffage du prochain tour** (clé `PREWARM`, **off** par défaut,
`loki config set PREWARM on`) : certains recalculs sont inévitables — prompt
réécrit par une compaction, dernier message rendu autrement au tour suivant,
discussion reprise après une tâche planifiée qui a pris le slot — et sans la
clé ils retardent le premier mot du message suivant. Avec `on`, dès que le
moteur local est libre après un tour ou une tâche, Loki lui envoie la requête
du prochain tour, assemblée par les mêmes fonctions que la vraie (contenu
vivant, rien de figé), suivie d'un message `.` et limitée à 1 jeton : le
moteur calcule le préfixe pendant que tu lis, et le vrai message ne calcule
plus que lui-même. Le jeton et le `.` sont jetés, rien n'est enregistré. Toute
autre requête de Loki l'annule aussitôt, sauf un message dont la requête
prolonge exactement le préfixe préparé (même historique, mêmes outils, mêmes
réglages du gabarit). Jamais avec plus d'un slot (`PARALLEL` ou `-np` dans
`EXTRA_ARGS`) — sauf les deux de `SIDE_SLOT`, où il prépare le slot de la
discussion —, pendant un tour, une tâche, un bench, ni vers un preset externe.
`full` prépare aussi la discussion qu'on ouvre, si on y reste 3 s. Visible dans
la télémétrie sous `prewarm`. Ce que voit le modèle ne change pas : au pire, le
préchauffage ne sert à rien (moteur sans points de reprise aux messages
utilisateur, image juste avant, agent ou web changé avant d'envoyer).
- **Compaction en continuation** (clé `COMPACT_CONTINUATION`, **off** par
défaut, `loki config set COMPACT_CONTINUATION on`) : sans la clé, le résumé
d'une compaction part dans une requête à part — un prompt de résumeur et une
transcription des anciens tours — que le moteur local calcule à froid, des
dizaines de secondes sur un 27B, des minutes sur un MoE. Avec la clé, quand le
slot porte encore le prompt de la discussion, Loki renvoie la requête du tour
telle qu'elle est partie (mêmes messages, outils, arguments du gabarit et
intensité de raisonnement) suivie d'une seule demande de résumé : le moteur ne
calcule que celle-ci. La demande désigne le premier message gardé tel quel,
demande de tout résumer avant lui et de dater l'état d'avancement à cet
endroit ; mêmes règles de résumé qu'avant (mode Code compris), même budget,
température 0.2 sans l'échantillonnage du preset, réflexion coupée,
`tool_choice` à `none`. Ce qui est compacté, archivé et rangé ne change pas :
la requête sert seulement à obtenir le résumé, et rien du prompt système ou du
projet n'entre dans l'historique. Repli sur la transcription au moindre
écart : autre requête passée par le slot depuis le tour (vérification du mode Code,
sous-agent, tâche, préchauffage, bench, client `/v1`, autre discussion),
moteur relancé sur un autre modèle ou une autre fenêtre, marge insuffisante
dans la fenêtre, refus du moteur, appel d'outil émis, résumé vide ou fait de
seul raisonnement ; un refus du gabarit ou un appel d'outil la suspend pour ce
modèle jusqu'au redémarrage (gpt-oss à intensité haute peut épuiser le budget
en réflexion : repli). La clé évite aussi un résumé voué au refus (même vide,
il ne réduirait pas le contexte de 20 %) et, après un refus faute de réduction,
n'en redemande pas avant que le contexte ait grossi de 10 % sur le même fil —
sauf à 90 % de la fenêtre ou après une édition, une régénération ou un fil
vidé. Le filet réactif (prompt refusé), le bouton « compacter », les tâches,
les sous-agents et un preset externe gardent le chemin d'avant. Visible dans la
télémétrie sous `compact`.
- **Images d'outils gardées** (clé `KEEP_TURN_IMAGES`, **off** par défaut,
**zone grise**, `loki config set KEEP_TURN_IMAGES on`) : sans la clé, une
image montrée par un outil (`web_screenshot`, `browser_screenshot`,
`see_image`) n'est vue que pendant le tour ; au tour suivant elle manque à
l'historique, et toute la boucle d'outils qui la suivait — souvent des
dizaines d'étapes de navigation — est recalculée. Avec `on`, elle reste dans
l'historique telle qu'envoyée, rangée par référence (`chatimg/`, chiffrée si
la mémoire l'est), sous conditions : vision réellement active (projecteur
chargé, revérifié à chaque tour — un preset sans vision n'en reçoit jamais) ;
coût mesuré par le moteur (écart du nombre de jetons du prompt entre deux
requêtes), et une image qu'on ne sait pas mesurer reste éphémère ; toutes les
images gardées tiennent dans 10 % de la fenêtre, la première qui dépasse et
celles qui la suivent dans le tour restent éphémères. Plus d'images gardées,
c'est plus de contexte, donc une compaction (avec perte) plus tôt : toute
compaction ou réduction forcée retire ces images **d'abord** (leur légende et
une mention « image non gardée » restent) et ne résume ensuite que si c'est
encore nécessaire ; rouvrir la discussion les retire aussi, comme les pièces
jointes. Discussion seulement (ni tâche, ni sous-agent, ni vérification). Un
preset externe n'est concerné que si sa vision est déclarée, et l'API
refacture alors ces images à chaque tour. Le gain suppose que le moteur
reprenne son cache au-delà d'une image ; sur un modèle hybride (Qwen3.5/3.6),
dont les points de reprise ne suivent pas toujours une image, à vérifier dans
la télémétrie (`lost` du tour suivant) avant de l'adopter. Sans la clé, la
requête est identique à l'octet près.
- **Rappel de budget dans le résultat d'outil** (clé `NUDGE_IN_TOOL`, **off**
par défaut, **zone grise**, `loki config set NUDGE_IN_TOOL on`) : après un
grand nombre d'appels d'outils dans un même tour, Loki rappelle au modèle de
conclure, dans un message à part. Sur un gabarit dont le rendu change dès
qu'un message s'ajoute (Qwen3.5 : ce message devient la « dernière question »
et tout le tour est rendu autrement), ce rappel fait recalculer toute la
boucle d'outils du tour. Avec `on`, le rappel part au bout du dernier résultat
d'outil, et seul ce bout est à calculer — sur le moteur local, et seulement
si la sonde de gabarit a conclu que le rendu de ce modèle bouge pour la forme
de cette requête — mêmes outils, `chat_template_kwargs` et niveau de
raisonnement (« inconnu » ou forme jamais sondée : message à part, comme
sans la clé). Le texte du rappel ne change pas, sa
place si : un modèle entraîné à se méfier des consignes lues dans une sortie
d'outil peut moins bien le suivre. À comparer (tours qui concluent après le
rappel) avant de l'adopter.
- **Second slot pour les travaux annexes** (clé `SIDE_SLOT`, **off** par
défaut, `loki config set SIDE_SLOT on`, moteur relancé) : avec un seul slot,
une vérification du mode Code, un sous-agent, une tâche planifiée ou un bench
prennent la place de la discussion ; son état part dans le cache RAM et en
revient — ou est recalculé s'il n'y tient plus. Avec `on`, le moteur ouvre
deux slots de `CTX` jetons **chacun** (`-c` 2×`CTX`, `--parallel 2`,
`--no-kv-unified`) : deux flux de cache KV séparés, si bien qu'un slot ne
peut jamais manquer de place à cause de l'autre, et que les travaux annexes
gardent toute la fenêtre. Chaque requête de Loki porte alors son `id_slot` :
le tour, ses étapes, le préchauffage et le résumé en continuation sur le
slot 0 ; vérification, sous-agents, tâches, bench, résumé sur transcription
et clients `/v1` (forcés, quel que soit l'`id_slot` demandé) sur le slot 1.
L'état de la discussion ne bouge plus ; l'effacement de slot après un
travail annexe (`CACHE_ISOLATE`) n'a plus lieu d'être et s'abstient. Le prix :
le cache KV et l'état récurrent d'un hybride en double en VRAM (le
brouillon MTP et les points de reprise en RAM hôte aussi) — l'éditeur de
preset affiche ce surcoût sous le cache de prompts, et le lancement refuse la
clé (un slot, comme sans elle, avec une note dans le journal) si modèle et
deux états dépassent 90 % de la VRAM, si la VRAM est inconnue (macOS, AMD),
si des poids sont sur CPU (`--n-cpu-moe`, `-ot …=CPU`, `NGL` partiel), si le
moteur ne connaît pas `--no-kv-unified`, ou si `PARALLEL`≠2, `-c`, `-np`,
`-kvu`, `--kv-unified-per-slot`, `--cache-idle-slots` (ou leurs
`LLAMA_ARG_*`) sont réglés à la main. Le routage ne s'active que si le moteur
annonce bien deux slots d'au moins `CTX` jetons (`/props`) ; sinon les
requêtes partent sans `id_slot`, comme avant. Ce que voit le modèle ne
change pas : deux slots qui calculent en même temps donnent des logits égaux
au bruit de virgule flottante près, comme un découpage de lots différent.
Un « cache KV plein » sans nombre de jetons (le pool, pas le prompt) est
rejoué tel quel et ne déclenche jamais de compaction.
- **État du slot gardé à la bascule de preset** (clé `SLOT_PERSIST`, **off**
par défaut, `loki config set SLOT_PERSIST on`) : passer d'un preset A à B
puis revenir à A relance le moteur deux fois, et la conversation de A est
recalculée en entier au retour. Avec `on`, juste avant une bascule faite
depuis l'interface ou par une tâche, Loki demande au moteur d'écrire l'état
du slot de la discussion (`/slots/0?action=save`, sous `LOKI_HOME/slots`),
et le recharge au retour, avant le premier message de cette discussion.
Conditions strictes, sinon rien n'est écrit ou rechargé et le calcul est
normal : la dernière requête passée par le slot était bien un tour de
cette discussion (pas une vérification, une tâche, un préchauffage ou un
client `/v1`), au moins 4096 jetons, au plus 8 Gio estimés et le double
de place libre ; au retour, le moteur doit avoir **exactement** la même
empreinte — ligne de commande complète (modèle, `CTX`, types KV, `NGL`,
lots, gabarit, `EXTRA_ARGS`…), variables `LLAMA_ARG_*`/`GGML_*`/`CUDA_*`,
taille et date du modèle, du projecteur, du binaire et de ses bibliothèques
— et son slot 0 ne doit encore avoir servi aucune tâche. Une mise à jour du
moteur change l'empreinte : jamais de reprise d'un build à l'autre. Une
seule tentative par fichier, retiré ensuite ; deux fichiers au plus,
supprimés dès que la clé est retirée. Réglage de la machine : il survit aux
bascules comme `HOST` (un preset qui le pose l'emporte). Refusé au lancement (note au journal)
avec le décodage spéculatif (`SPEC`, brouillon dans `EXTRA_ARGS` : la
sauvegarde du moteur ne garde pas l'état du brouillon, et un brouillon MTP
détecté sur le slot bloque aussi), des poids sur CPU (MoE déporté), un
`--slot-save-path` déjà réglé, ou un moteur joignable par d'autres sans clé
d'API (`HOST` autre que `127.0.0.1`). `loki switch` en ligne de commande ne
garde rien (seul le process web sait ce que porte le slot). Le gain est
réel surtout sur un modèle dense ; sur un hybride (Qwen3.5/3.6), le fichier
n'a pas de points de reprise et ne sert que si le message suivant prolonge
exactement les jetons gardés (gabarit qui rend le dernier tour à
l'identique), sinon recalcul comme avant. Ce que voit le modèle ne change
pas : llama.cpp ne reprend un état que sur un préfixe de jetons identique.
Avec `PREWARM`, le préchauffage passe par le slot après chaque tour : une
bascule faite ensuite ne garde rien (les deux clés se recouvrent peu). Une
simple lecture d'un client (`/v1/models`, `/health`) ne compte pas.
- **Spéculation par n-grammes** (clé `SPEC`, valeurs `ngram` et `mtp+ngram`,
**off** par défaut, `loki config set SPEC ngram`, moteur relancé) : en mode
Code, le modèle recopie sans cesse ce qui est déjà dans le contexte (chemins,
diffs, arguments JSON, fichiers réécrits). `ngram` y cherche la suite des
24 derniers jetons et propose d'un coup les 48 à 64 suivants
(`--spec-type ngram-mod --spec-ngram-mod-n-match 24 --spec-ngram-mod-n-min 48
--spec-ngram-mod-n-max 64`, toujours en clair, jamais `--spec-default` dont
le contenu peut changer d'une version à l'autre) ; `mtp+ngram` ajoute la tête
MTP (`--spec-type draft-mtp,ngram-mod`), les n-grammes passant d'abord quand
ils trouvent. Chaque jeton reste tiré par le modèle et un jeton proposé n'est
gardé que s'il coïncide : même distribution, mais pas le même texte au bit
près (la vérification par lots n'emprunte pas les noyaux du décodage jeton
par jeton) — on compare des sessions rejouées, pas des diffs. Rien n'est posé
si le moteur ne connaît pas `--spec-ngram-mod-n-match`, si `EXTRA_ARGS` ou
`LLAMA_ARG_SPEC_TYPE` règlent déjà la spéculation (`--spec-type`, qui
s'additionne au lieu de remplacer, `--spec-default`, `--spec-ngram-*`, `-md`…),
ou, pour `ngram`, si un `--spec-draft-*` y figure. `mtp+ngram` sans tête MTP
(ni dans le fichier ni en `MODEL_DRAFT`) ou sans `draft-mtp` dans le moteur
retombe sur les n-grammes seuls, avec une note. Avec `ngram`, une tête MTP
présente reste inutilisée (le type explicite coupe le choix automatique) : la
note le dit. Poids sur CPU (`--n-cpu-moe`, `-ot …=CPU`, `NGL` partiel) :
refusé tant que `GGML_OP_OFFLOAD_MIN_BATCH` (clé `OP_OFFLOAD_MIN_BATCH`) ne
dépasse pas le lot de vérification de 65 jetons — sinon chaque brouillon
recopierait les experts vers le GPU ; `OP_OFFLOAD_MIN_BATCH=128` pour
l'essayer, en mesurant aussi les lectures disque si le modèle dépasse la RAM.
Sur un hybride, chaque brouillon copie l'état récurrent (point de reprise)
et le recharge s'il est rejeté. `/api/perf/summary` donne l'acceptation par
nature de requête (`kinds.<nature>.draft_rate`) : à garder là où elle paie.
Les drapeaux d'acceptation synthétique (`--spec-synth-*`, « benchmarking
only »), que Loki ne pose jamais, déclenchent un avertissement au lancement.
- **Parallélisme de tenseurs** (clé `SPLIT_MODE=tensor`, **off** par défaut,
expérimental, moteur relancé) : au lieu de donner à chaque carte des couches
entières (`-sm layer`, défaut), chaque couche est coupée entre les cartes,
qui additionnent ensuite leurs morceaux. L'amont mesure un décodage plus
rapide mais un prefill nettement plus lent : sur deux GeForce en PCIe et une
boucle d'agent qui relit beaucoup, à mesurer par tour avant de l'adopter.
Loki n'active le mode que si l'aide du moteur liste `{none,layer,row,tensor}`
(le mot « tensor » seul est partout), et ajoute alors `-ngl all`, `-ts` au
prorata de la VRAM totale des cartes dans l'ordre du moteur (`--device`
compris), `-fa on` — chacun seulement s'il n'est pas déjà dans `EXTRA_ARGS`
ou une `LLAMA_ARG_*` — et `GGML_CUDA_ALLREDUCE=internal` si l'environnement
ne le fixe pas : le chemin NCCL compresse en BF16 les grosses réductions du
prefill, une perte de précision ; la réduction interne garde le type natif.
« NCCL not compiled in » au journal est alors sans effet ; `GGML_CUDA_P2P=1`
reste à poser soi-même si le pilote le permet. Pas de `--fit` en mode
tensor : Loki estime poids + cache de `CTX` jetons et refuse au-delà de 90 %
de la VRAM — jamais en réduisant `CTX`. Refusé aussi (découpe par couches,
note au journal) avec un cache KV autre que f16/bf16/f32 (jamais réécrit
d'office), des poids ou le cache KV sur CPU (`--n-cpu-moe`, `-ot`, `NGL`
partiel, `-nkvo`), `--backend-sampling`, `-fa off`, un `-sm` déjà réglé,
`SPEC`, `SIDE_SLOT`, `CTX` non chiffré, une seule carte ou une carte non
CUDA, une architecture que llama.cpp exclut (et `qwen4exp`), un preset
externe ; `SLOT_PERSIST` est refusé avec la clé.
- **Guide de placement entre cartes inégales** (éditeur de preset, groupe
« Cartes graphiques », dès deux cartes ; un conseil, rien n'est appliqué
d'office) : chaque carte CUDA affiche sa liaison PCIe maximale (génération,
largeur), l'actuelle et l'horloge mémoire en info-bulle — lues par
`nvidia-smi` en une requête, jointes par nom de carte (rien pour deux
cartes homonymes ni pour un moteur Vulkan), avec repli sur la requête
historique si un vieux pilote refuse un champ. `loki gpu` les affiche aussi.
Le guide rappelle les deux règles du moteur, dans l'ordre qu'il voit
(`--device` compris) : en placement auto, `--fit` remplit d'abord la
**dernière** carte, qui porte la couche de sortie (modèle dense : la plus
rapide en dernier, ou une marge `FIT_TARGET` plus large sur la lente) ; les
experts MoE en RAM sont recopiés au prefill vers la **première** (à elle la
liaison la plus large). Les marges `FIT_TARGET` du preset sont montrées carte
par carte ; un `--tensor-split` (qui désactive `--fit`) ou un
`CUDA_VISIBLE_DEVICES` propre au preset (ordre réel différent) est signalé.
Un lien « inverser l'ordre » écrit `--device` (et retourne `--tensor-split`)
dans le preset édité. Pas de bouton de mesure automatique : comparer deux
ordres demande deux rechargements du moteur et un ordre inversé peut manquer
de VRAM sur la petite carte — dupliquer le preset, inverser l'ordre dans la
copie, « bench complet » sur chacun, et vérifier `offloaded N/N layers`.
- **MoE aux experts sur CPU : avis et copie en placement auto** (jamais de
réécriture du preset). Au lancement (journal) et dans l'éditeur, sous
« Experts MoE sur CPU », pour un modèle dont le GGUF annonce des experts :
experts placés à la main (`-ot` visant `…exps…` vers le CPU, `--n-cpu-moe`,
`--cpu-moe` — un `-ot` de la seule couche d'entrée `per_layer_token_embd` ne
compte pas) → `--fit` ne les place pas, et monter `UBATCH` seul grossirait
le tampon de calcul de chaque carte sans personne pour rééquilibrer (monter
`--n-cpu-moe` d'abord) ; placement auto avec un modèle plus gros que la VRAM
et `UBATCH` ≤ 512 → « UBATCH 2048+ recommandé » (les experts restés en RAM
sont recopiés vers le GPU à chaque micro-lot du prefill) ; moteur sans
`--fit` → « garde `--n-cpu-moe` » ; `--fit` coupé par un autre `-ot` → rien
ne sort les experts du GPU. Le bouton **« Dupliquer en placement auto… »**
crée une **copie** du preset affiché, sous « (placement auto) », sans la
bascule : `-ot` des experts (et celui de `per_layer_token_embd`, redondant :
llama.cpp garde toujours la couche d'entrée au CPU), `--n-cpu-moe`,
`--cpu-moe`, `-ngl`, `--tensor-split`, `--fit off`, `-b`/`-ub` et drapeaux
de chargement retirés ; `NGL` retiré (`-ngl auto`), `UBATCH=2048`,
`BATCH=4096`, `--load-mode mmap` ; `CTX`, cache KV (quantifié ou non : à
garder identique pour comparer), échantillonnage intacts. Refusé si la copie
ne tournerait pas en `--fit` (autre `-ot`, `-sm row/tensor`, `SPLIT_MODE`,
`--n-cpu-ffn`) ou si `CTX` vaut 0. Après bascule : `successfully fit params`
au journal (`failed to fit` = tous les experts sur GPU, revenir à
l'original), puis bench complet des deux.
- **Garde du mode de chargement** (clé d'échappement `LOAD_GUARD=off`, dans
le preset) : `--load-mode none`, `mlock`, `dio`, `mmap+mlock` (ou
`--no-mmap`, `--mlock` sur un moteur ancien, ou leurs `LLAMA_ARG_*`) gardent
en RAM tous les poids laissés au CPU. Loki en estime la part — modèle
(toutes tranches) moins VRAM totale, tout le modèle sur un Mac Apple Silicon — face à la
RAM effective (limite du conteneur cgroup comprise) : avertissement au-delà
de 80 % (llama.cpp #26110 : swap, décodage de 25 à 7,5 t/s ; mlock : processus
tué) ; **refus** au lancement seulement quand la borne basse dépasse 90 %
(échec certain), avec la raison en clair, aussi affichée par l'interface.
Le refus sort avec le code 78, que l'unité systemd écrite par
`loki install` ne relance pas (`RestartPreventExitStatus=78`) ; une unité
d'une version précédente (ou launchd) reçoit une sortie sans erreur pour ne
pas boucler — `sudo systemctl edit loki-engine` (section `[Service]`,
`RestartPreventExitStatus=78`) met l'unité à jour.
La VRAM qui fonde un refus est celle des cartes que le moteur liste
lui-même (`--list-devices`, lu seulement quand un refus se profile sur la
VRAM NVIDIA) : un moteur Vulkan sur cartes mixtes NVIDIA + AMD compte les
deux. Liste illisible, VRAM inconnue (Mac Intel, AMD seule, `--device`) ou
serveurs `--rpc` : avertissement, jamais de refus. `mmap` et `auto` ne sont jamais concernés ;
`LLAMA_ARG_NO_MMAP`/`LLAMA_ARG_MLOCK` ne comptent que sur un moteur ancien
(un moteur à `--load-mode` les ignore). Rien ne change dans la ligne de
commande.
- **Optimiseur sans perte** (`loki tune`, bouton **« Optimiser… »** dans
l'éditeur du preset en service ; rien ne tourne de soi-même, aucune clé de
config). Cherche, pour cette machine et ce build du moteur, les réglages
d'ordonnancement qui raccourcissent un tour de conversation, sans rien changer
à ce que calcule le modèle (sortie équivalente en distribution : les sommes
flottantes ne sont pas identiques au bit près d'un lot ou d'un placement à
l'autre ; la spéculation vérifie chaque jeton). **Isolation** : le vrai moteur
est arrêté (comme « décharger la VRAM »), chaque essai est une **copie** de la
configuration avec ses surcharges dans `LOKI_HOME/tune/run/` — `config.env`
n'est jamais écrit — lancée par `loki serve` sur `127.0.0.1` et un port libre,
mêmes `CUDA_VISIBLE_DEVICES` ; son groupe de processus est tué en sortie
quoi qu'il arrive, puis le vrai moteur est relancé. **Verrou** exclusif entre
processus (`LOKI_HOME/tune.lock`, PID + instant de démarrage + binaire) :
pendant la mesure, tout démarrage du moteur (`serviceAction`, toutes
plateformes), bascule ou enregistrement de preset, choix des GPU, clé d'API,
mise à jour ou recompilation du moteur, rechargement de la VRAM, bench, chat,
compaction, `bash_bg` et clients `/v1` (503) sont refusés avec une phrase
claire ; les tâches planifiées attendent. Un verrou dont le propriétaire est
mort (Loki tué en plein essai) est écarté avant tout démarrage du moteur :
l'essai orphelin n'est arrêté que si PID, instant de démarrage ET binaire
concordent (`taskkill /T` sous Windows), et le moteur est relancé s'il
tournait avant — au démarrage de l'interface, puis toutes les 30 s par le
processus web (un `stat` tant qu'aucun verrou n'existe) : un `loki tune` tué
par `kill -9` ne laisse plus le moteur arrêté. **Refus d'entrée** : preset
externe, aucun preset actif, génération, tâche, bench ou job `bash_bg` en
cours, moteur occupé (`/slots`) — en ligne de commande, seul ce dernier
contrôle voit le processus web : préférer le bouton. En ligne de commande
aussi, un moteur qui ne serait pas celui de ce `LOKI_HOME` (il répond alors
que ce dossier le dit arrêté, refuse sa clé d'API ou sert un autre `MODEL` :
typiquement `sudo loki tune`, qui perd un `LOKI_HOME` exporté) fait
refuser, avec la marche à suivre. **Essais** (descente étape par étape depuis
la meilleure configuration du moment, chaque axe seulement si l'aide du
moteur et la machine le permettent) : placement — seulement avec « inclure
le placement » / `--placement` : `--fit` à la place des experts placés à la
main (`-ot …exps…`, `--n-cpu-moe`, `--cpu-moe`) ou d'un `--tensor-split` ;
marges `FIT_TARGET` (2 cartes ou plus, `--fit-target` dans l'aide) ;
`UBATCH` ∈ {512, 1024, 2048, + 4096 pour un MoE} × `BATCH` ∈ {max(2048, ub),
2 × ub ≤ 8192}, lot > micro-lot sur 2 cartes tout GPU, jamais de micro-lot
plus grand sans `nvidia-smi` ; `OP_OFFLOAD_MIN_BATCH` ∈ {32, 128, 512} et
threads (omis = cœurs physiques, physiques − 1, moitié ; Linux) seulement
avec des poids sur CPU ; `CUDA_LAUNCH_QUEUES` off/4x sur 2 cartes ou plus ;
avec « inclure les options opt-in » / `--opt-in` : `SPEC=auto`,
`SPEC_N_MAX` ∈ {2, 3, 4}, `CUDA_GRAPH_OPT=on`, `--backend-sampling`. Un
réglage qu'`EXTRA_ARGS` écraserait (`-ub`, `-t`, `-fitt`…) est réglé **dans
EXTRA_ARGS, sur place** ; EXTRA_ARGS est traité jeton par jeton et seuls les
drapeaux de la liste blanche (`-ot` des experts, `-ncmoe`, `-cmoe`, `-sm`,
`-ts`, `-mg`, `-fit`, `-fitt`, `-b`, `-ub`, `-t`, `-tb`, + `--spec-draft-n-max`
et `-bs` en opt-in) bougent ; tous les autres jetons (`--cache-type-k`,
`--flash-attn`, `--chat-template-file`…) ressortent à l'octet près. **Jamais
touchés** : `MODEL`, `CTX`, `KV_TYPE*` (un cache déjà quantifié est signalé
zone grise, jamais modifié), `MMPROJ`, `REASONING*`, `TEMP` et autres clés
d'échantillonnage, `PARALLEL`, `NGL`, `SPLIT_MODE`, `CUDA_VISIBLE_DEVICES`.
**Garde-fous** : chaque essai est d'abord composé à blanc (`loki serve` sans
rien charger) et sa ligne de commande comparée à celle de référence hors
réglages permis — toute autre différence (contexte, cache KV, flash-attn,
slots, variable du moteur…) l'écarte comme « dénature » ; une fois chargé,
`n_ctx`, `total_slots` (`/props`) et les types du cache KV (journal) sont
recomparés ; moins de couches déportées que la référence = « fit en recul »
(performance), écarté ; moins de 768 Mio de VRAM libre sur une carte après la
mesure (+ 512 avec `MMPROJ`) = « trop juste », jamais retenu ; échec de
chargement ou OOM = essai ignoré ; deux essais à la même ligne de commande ne
sont mesurés qu'une fois. **Mesure** : le bench complet (prefill à froid à une
profondeur D fixe pour tous les essais, trois tours qui reprennent le cache —
points de reprise réels d'un hybride compris —, decode à D, ligne courte en
prose), avec l'échantillonnage et le raisonnement du preset. **Score** : durée
d'un tour type = K / prefill des tours + G / decode à D + f × D / prefill à
froid, K, G et f tirés des médianes de la télémétrie (`/api/perf/summary`)
quand elle en a vu assez, sinon 2000, 600 et 5 %. La référence est mesurée
deux fois ; un essai ne gagne que s'il bat la meilleure configuration du
moment de plus de max(3 %, écart entre passages) sur **deux** passages, sans
ralentir de plus de 5 % le decode en prose. Budget par défaut 30 min
(`--budget`), ETA et annulation ; au-delà, résultat partiel dit tel ; étapes de
base à la carte (`--stages lots,threads…`, cases de la fenêtre). **Résultat**
: les mesures de chaque essai et le diff des clés, enregistrés par preset
avec l'empreinte et le build du moteur (l'éditeur propose de relancer après
une mise à jour du moteur). **Rien n'est écrit sans un clic** : « enregistrer
dans une copie » (preset « (optimisé) », non activé) ou « appliquer à ce
preset » après confirmation du diff — et une confirmation de plus si
EXTRA_ARGS est réécrit par le placement. Le preset est sauvegardé
(`LOKI_HOME/tune/backup/`), réécrit clé par clé (commentaires et ordre
intacts), réappliqué ; la configuration active doit alors être exactement la
référence plus les clés réglées, le moteur redémarre et doit répondre avec le
même contexte et les mêmes slots puis tenir une sonde (prompt de D jetons et
decode) — sinon l'ancienne version est rétablie et le moteur relancé. Pendant
la mesure, le chat est indisponible : prévoir 10 à 40 minutes selon la taille
du modèle (un modèle relu depuis un disque lent recharge à chaque essai).
## Différences avec l'amont
| | AJEAN (amont) | Loki (ce fork) |
@@ -374,7 +1120,7 @@ Retirées par ce fork :
| Agent de code | — | mode Code : critères d'acceptation, passe de vérification, LSP, outils git |
| Accès distant | relais chiffré ajean.link | retiré de l'interface |
| Endpoint OpenAI | `:8080/v1` du moteur, ouvert par `network on` | `/v1` servi par Loki sur le port de l'interface, protégé par la clé API |
| Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` |
| Mise à jour | `ajean update` (binaire GitHub) | au redémarrage du conteneur (release `rolling`) ; `docker compose pull` si l'image change |
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
Pour récupérer les évolutions de l'amont :
@@ -435,6 +1181,40 @@ courant n'est pas touché. Le moteur livré par l'image reste par ailleurs intac
Quand cette limite est atteinte pour de bon (CUDA majeur trop ancien), la
solution reste le rebuild avec un `LLAMACPP_IMAGE` récent, ci-dessus.
**Version recommandée.** Quand le moteur officiel qui tourne est antérieur à
`b10864`, le panneau affiche un encart qui dit ce que la mise à jour apporte :
les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next) ne sont plus
évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise —, le MTP
rapide (graphes CUDA) arrive à `b11009` et le MTP de qwen4exp à `b11331`. Le
décodage spéculatif reste coupé tant que `SPEC` n'est pas posé. L'encart se
décide sans réseau, sur le build du moteur (jamais pour un llama.cpp compilé ou
un fork, dont le numéro ne prouve rien). Le bouton **Mettre à jour vers la
version recommandée** n'interroge le registre qu'au clic : il prend le dernier
build publié, vérifie qu'il atteint `b10864` et que son tag figé
(`server-cuda-bNNNNN`) existe bien, puis demande confirmation. Sans réseau, il
le dit et n'installe rien. Rien n'est jamais mis à jour d'office, ni au
démarrage.
**Revenir à la version précédente.** Après une mise à jour, la version
téléchargée qui tournait juste avant est gardée (les plus anciennes partent,
~450 Mo chacune) ; le bouton **Revenir à la version précédente** y ramène sans
réseau, et la version quittée devient à son tour « la précédente ».
**Le rendu du raisonnement.** Depuis `b10763`, llama-server garde par défaut la
réflexion des tours passés dans le gabarit (`preserve_reasoning`). Un gabarit
qui la retirait de lui-même (Qwen3.6, gabarits à `clear_thinking`) la rend alors
— vide, puisque Loki ne la renvoie pas sans `REASONING_ECHO` : le modèle
relirait un historique où il n'a jamais réfléchi. Avant de basculer vers un tel
moteur, Loki lit le gabarit chargé et le verdict de la sonde de gabarit ; si le
prompt rendu va changer et que `REASONING_PRESERVE` n'est pas posé, la
confirmation l'explique et propose de poser `REASONING_PRESERVE=off` dans le
preset actif (case cochée seulement quand le changement est établi :
sur un gabarit qui garde déjà la réflexion, comme Qwen3.8, `off` changerait à
son tour le rendu). L'utilisateur choisit. Après la bascule, le rendu de
quelques conversations synthétiques est comparé, via `/apply-template`, entre
l'ancien et le nouveau moteur une fois le modèle chargé : le panneau dit s'il
est identique, ou montre le premier écart.
Derrière un miroir de registre ou un réseau qui n'atteint pas ghcr.io :
`LOKI_OCI_REGISTRY=https://mon-miroir.interne` dans l'environnement du conteneur.
+130 -34
View File
@@ -1,53 +1,149 @@
# Loki 0.12.0
# Loki 0.15.0
Le sélecteur de modèle devient un vrai sélecteur, la dictée vocale arrive, et
les cartes de raisonnement suivent l'écriture au lieu d'envahir la page.
Une version entièrement consacrée à la vitesse, avec une règle tenue de bout en
bout : **ne jamais dénaturer le modèle**. Rien ne touche à la quantification
des poids, à l'échantillonnage, à la température, au budget de raisonnement ni
au contenu du contexte. Ce qui est sans perte est actif d'office ; tout ce qui
changerait ce que voit le modèle, le placement des poids ou les slots du moteur
attend une clé. Sans clé, la requête et la ligne de commande restent celles
d'avant.
## Le sélecteur de l'en-tête charge vraiment un modèle
La section **Performance** du README rassemble toutes les clés, leur défaut et
leur prix.
Deux groupes : « Presets » et « Modèles (.gguf) » — tous les fichiers de
`/models` et `/data/models`. Choisir un modèle le charge (seul `MODEL` change,
contexte/NGL/échantillonnage conservés) et redémarre le moteur. Sans preset
créé, le sélecteur n'offrait aucun choix.
## Actif d'office, sans perte
## Dictée vocale
**La conversation n'est plus recalculée pour rien.**
Un bouton micro dans la carte de saisie : tu parles, le texte s'écrit. La
transcription est 100 % locale — whisper.cpp compilé dans l'image, modèle
multilingue téléchargé au premier usage (~190 Mo, dans `/data/whisper/`).
Le navigateur exige HTTPS (ou localhost) pour donner accès au micro.
- **Cache de prompts dimensionné** : le moteur garde en RAM une copie exacte des
conversations qu'il quitte. Son défaut ne tenait pas une conversation de 30 à
65 k jetons à côté d'un vérificateur ou d'un sous-agent : il l'évinçait, et
tout était recalculé (30 à 80 s sur un 27B). Il est maintenant agrandi pour un
modèle tout-GPU, et le slot est effacé après chaque travail annexe pour que la
conversation revienne du cache.
- **Préfixe stable** : les rappels de Loki entrent dans l'historique tels
qu'envoyés, la relance après un 500 ne change plus les outils, la ligne MCP ne
manque plus au premier tour après un démarrage, l'ordre des trackers ne bouge
plus. Chacun de ces écarts faisait recalculer toute la boucle d'outils.
- **Compaction moins tôt** : le raisonnement que Loki ne renvoie jamais ne
compte plus dans le contexte. La compaction (qui perd de l'information)
partait vers 44-47 k au lieu de 49 k sur une fenêtre de 65 k. Son résumé n'est
plus amputé de l'état d'avancement.
- **Hybrides (Qwen3.5/3.6) sur un moteur ancien** : `--checkpoint-min-step 2048`
d'office quand la RAM le permet, pour ne plus retomber jusqu'à ~8 k jetons en
arrière à chaque reprise.
## Cartes raisonnement/outils : taille fixe, défilement qui suit
**Le matériel est mieux employé.**
Hauteur bornée (280 px) avec défilement interne collé au texte pendant la
génération — remonter lire le début est respecté. Les cartes d'outil suivent
la ligne en cours d'écriture puis remontent en tête une fois l'appel terminé.
Sur un raisonnement géant, seul le bas du bloc est re-rendu en direct (le
texte complet est posé à la fin) : l'affichage ne se fige plus.
- **Threads CPU** : « auto » veut dire les cœurs physiques, plus tous les
threads logiques — c'est le décodage des experts MoE sur CPU qui payait.
- **Deux GPU** : file de lancements CUDA élargie quand le pipeline entre cartes
est possible (même calcul ; seul le prompt peut gagner).
## Mesures
**Loki lui-même pèse moins.**
- La carte du temps total affiche la **vitesse moyenne de la conversation**
(« en cours — 12 s · moy 21.3 tok/s »), rejouée au rechargement.
- Le **pourcentage de chargement** du modèle bouge aussi sur un redémarrage à
chaud (modèle déjà dans le cache disque : on suit la mémoire résidente, plus
seulement les octets lus).
- Écrire un gros fichier ne coûte plus un temps quadratique côté Go (jusqu'à
8 s de CPU volées au décodage sur un MoE).
- L'enregistrement de la discussion ne retarde plus le premier jeton.
- Un seul `nvidia-smi` pour tous les onglets, aucun pour un onglet caché, un
seul à l'ouverture de l'éditeur de preset.
- Unraid : un `/data` ou `/models` servi par la couche FUSE est signalé, avec
le remède.
## Interface
**On peut enfin mesurer.**
- Discussions triées par date de **création** (récentes en tête) : une
discussion garde sa place, écrire dans un vieux fil ne le fait plus remonter.
- Bouton **Réglages** ancré en pied de barre latérale, au-dessus du moniteur
Performance.
- Chaque réponse dit ce qu'elle a repris du cache, recalculé et accepté du
brouillon ; `GET /api/perf/summary` en fait la synthèse par nature de requête
(`lost_events`, `lost_tokens` : le cache perdu, et après quoi).
- **Bench** honnête, en tâche de fond : mode complet à profondeur réelle, tours
qui reprennent le cache, rien d'enregistré sans timings réels.
- Une sonde du gabarit de chat dit, sans rien envoyer au modèle, si le rendu
reste stable d'un message à l'autre.
## Rappel 0.11.0
**Garde-fous.** Un cache KV quantifié, `--context-shift` ou `--cache-reuse`
posés à la main sont signalés. Un mode de chargement résident voué à l'échec
(poids restés en RAM au-delà de 90 %) est refusé avec la raison en clair —
`LOAD_GUARD=off` pour passer outre ; le refus n'est plus relancé en boucle par
systemd, et un moteur Vulkan sur cartes mixtes n'est plus refusé à tort.
Réglages en modale à deux volets ; nom du modèle sur chaque réponse
(journalisé, il survit au rechargement) ; jeton Hugging Face réglable dans
l'interface ; tâches planifiées reprises de l'amont.
## En opt-in : à essayer, à mesurer
| Clé | Ce qu'elle fait |
|---|---|
| `PREWARM=on` | prépare le prochain tour pendant que tu lis (`full` : aussi la discussion qu'on ouvre) |
| `COMPACT_CONTINUATION=on` | le résumé de compaction prolonge le prompt en cache au lieu d'être calculé à froid |
| `PROJ_SNAPSHOT=on` | bloc projet figé par discussion, changements livrés à part ; date et dossier sortent du système |
| `REASONING_ECHO=on` | renvoie au moteur local la réflexion du modèle, au format entraîné |
| `SPEC=auto` / `mtp` | décodage spéculatif MTP (tête détectée dans le fichier, garde-fous VRAM) |
| `SPEC=ngram` / `mtp+ngram` | n-grammes du contexte, utiles en mode Code |
| `SIDE_SLOT=on` | second slot pour les travaux annexes (VRAM en double) |
| `SLOT_PERSIST=on` | état du slot gardé à la bascule de preset |
| `SPLIT_MODE=tensor` | parallélisme de tenseurs entre cartes (expérimental) |
| `FIT_TARGET`, `OP_OFFLOAD_MIN_BATCH`, `CUDA_GRAPH_OPT` | réglages d'expert pour cartes inégales et MoE |
| `KEEP_TURN_IMAGES=on` | **zone grise** : garde les images des outils dans l'historique |
| `NUDGE_IN_TOOL=on` | **zone grise** : rappel de budget dans le résultat d'outil |
Et deux outils qui ne font rien d'eux-mêmes :
- **Optimiseur** (`loki tune`, bouton « Optimiser… ») : cherche lots, threads,
marges et placement sur un moteur d'essai isolé ; rien n'est écrit sans un
clic, et l'application est vérifiée puis défaite si le moteur ne tient pas.
- **« Dupliquer en placement auto… »** pour un MoE aux experts placés à la
main : une copie du preset où `--fit` les répartit.
## Ordre de test conseillé
Une étape à la fois, mesurée avant la suivante :
1. **Bench complet** du preset tel quel : la référence.
2. **`PREWARM` + `COMPACT_CONTINUATION`**, une vraie session de travail, puis
`/api/perf/summary` : le cache perdu (`lost`) doit baisser.
3. **Optimiseur** sur le preset en service.
4. **MoE** : « Dupliquer en placement auto… », bench complet des deux.
5. **Mise à jour du moteur** vers la version recommandée (encart du panneau
Moteur).
6. **`SPEC=auto`** (ou `mtp`) si le modèle a une tête MTP.
## Moteur : version recommandée et retour arrière
Quand le moteur officiel est antérieur à `b10864`, le panneau Moteur dit ce
qu'une mise à jour apporte (points de reprise des hybrides, MTP rapide) et la
propose — sur clic seulement, version vérifiée sur le registre. La version
quittée est gardée : **Revenir à la version précédente** y ramène sans réseau.
Depuis `b10763`, llama-server garde par défaut la réflexion des tours passés :
un gabarit comme Qwen3.6 la rendrait vide. Avant de basculer, Loki le détecte et
propose `REASONING_PRESERVE=off` ; après, il compare le rendu de l'ancien et du
nouveau moteur et signale le premier écart.
## Ce qui a été écarté
- **`--cache-reuse`** : réutilise du cache calculé sous un autre contexte, donc
change les sorties.
- **`--context-shift`** : retire des jetons du contexte, et ne marche pas sur
les hybrides.
- **Cache KV quantifié par défaut** : `q8_0` n'est pas exact, `q4_0` perd
mesurablement, pour ~2 Gio gagnés sur un 27B hybride. Reste un choix
explicite (`KV_TYPE`).
## Autres corrections
- Une compaction en plein tour ne fait plus disparaître le rappel des pages
mémoire lues.
- Un `loki tune` tué net ne laisse plus le moteur arrêté ; lancé avec un autre
`LOKI_HOME` que l'interface (`sudo`), il refuse au lieu de charger un essai à
côté d'un moteur vivant.
- L'ancienne option « n-grammes (mod) » de l'éditeur rejoint `SPEC=ngram`, avec
une migration proposée (jamais faite d'office).
- Accès refusé : l'interface dit pourquoi et comment le lever.
## Mise à jour
```
docker compose pull && docker compose up -d
```
Installation systemd (`loki install`) : l'unité du moteur gagne
`RestartPreventExitStatus=78`. `loki update` ne réécrit pas les unités ;
`sudo loki install`, ou `sudo systemctl edit loki-engine` avec cette ligne dans
`[Service]`, la met à jour.
Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -2,14 +2,14 @@
"FixedFileInfo": {
"FileVersion": {
"Major": 0,
"Minor": 12,
"Patch": 3,
"Minor": 15,
"Patch": 0,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 12,
"Patch": 3,
"Minor": 15,
"Patch": 0,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
"OriginalFilename": "loki.exe",
"ProductName": "Loki",
"ProductVersion": "0.12.3",
"ProductVersion": "0.15.0",
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
},
"VarFileInfo": {
@@ -34,4 +34,4 @@
"CharsetID": "04B0"
}
}
}
}
+45 -2
View File
@@ -16,8 +16,10 @@
# Les modèles se téléchargent directement depuis l'UI (catalogue intégré),
# ou dépose tes .gguf dans /mnt/user/appdata/loki/models.
#
# Mise à jour : Compose Down puis Up avec « pull image », ou
# « docker compose pull » avant Up.
# Mise à jour : AUTOMATIQUE au redémarrage du conteneur (binaires Loki de
# la release « rolling », vérifiés SHA-256 ; LOKI_AUTO_UPDATE=0 pour couper).
# Si le journal demande une nouvelle image : Compose Down puis Up avec
# « pull image », ou « docker compose pull » avant Up.
# ───────────────────────────────────────────────────────────────────────────
services:
loki:
@@ -34,9 +36,50 @@ services:
# lancement). Ex. LOKI_MODEL=Qwen3-14B-Q4_K_M.gguf après dépôt du
# fichier dans /mnt/user/appdata/loki/models.
- LOKI_MODEL=
# Accès par NOM DE DOMAINE (reverse proxy, ex. loki.mondomaine.fr) sans
# clé de pilotage : liste ici le(s) nom(s), séparés par des virgules —
# sinon l'API répond 403 (protection contre les sites tiers / DNS
# rebinding). Inutile pour un accès par IP ou nom local.
- LOKI_TRUSTED_HOSTS=
# Conversation tenue par la façade Rust (défaut). « go » la rend au Go si
# quelque chose ne va pas — puis Compose Down / Up.
# - LOKI_CHAT=go
volumes:
# PERFORMANCE — /mnt/user/… passe par la couche FUSE des partages
# Unraid (shfs) : chaque écriture de la base et chaque page d'un gros
# modèle relue depuis le disque (modèle plus gros que la RAM) traverse un
# démon en espace utilisateur. Loki le signale au démarrage (encart
# d'information, rien n'est perdu). Deux remèdes, au choix :
# - Recommandé (Unraid 6.12+) : partages en « Exclusive access ».
# Ce n'est pas une case mais un état : Settings -> Global Share
# Settings -> « Permit exclusive shares » = Yes, puis le partage
# appdata (et celui des modèles) entièrement sur UN pool — mover
# d'abord vers le pool, ensuite Secondary storage = None. La page du
# partage affiche alors « Exclusive access : Yes ». Redémarre le
# conteneur : le chemin /mnt/user reste le même, FUSE est
# court-circuité.
# - Avancé : mapper /mnt/<ton-pool>/appdata/loki/… (ex. /mnt/cache si
# ton pool s'appelle « cache »). Migration MANUELLE obligatoire :
# Compose Down, rsync -a de l'ancien dossier vers le nouveau, puis
# changer les deux lignes ci-dessous. Un /mnt/<pool> qui n'existe
# pas atterrit dans la RAM d'Unraid : données perdues au redémarrage,
# et un modèle de 80 Go peut saturer la mémoire du serveur.
# Ne change JAMAIS ce chemin sans migrer : /data apparaîtrait vide.
# /data : config, base, mémoire, workspace, modèles téléchargés par l'UI
- /mnt/user/appdata/loki/data:/data
# /models : dossier de .gguf déposés à la main (gros fichiers)
- /mnt/user/appdata/loki/models:/models
# Strata sur un SSD dédié (optionnel) : tout ce que Strata installe
# (moteur, Python, modèle de 66 à 84 Go, experts lus en mmap) va dans
# /data/strata. Monter un SSD à cet endroit l'y envoie entier. Le SSD
# doit être un pool Unraid (Main -> Add Pool, ex. « strata ») : chemin
# /mnt/<pool>/…, PAS /mnt/user/… (FUSE ralentit le mmap des experts).
# Vérifie que /mnt/strata existe avant Compose Up : sinon Docker crée
# le dossier dans la RAM d'Unraid. Détails : README, « Strata sur un
# SSD dédié ».
# - /mnt/strata/loki:/data/strata
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
ulimits:
memlock: -1
restart: unless-stopped
+7
View File
@@ -22,6 +22,9 @@ services:
- LOKI_MODEL=${LOKI_MODEL:-}
- LOKI_CTX=${LOKI_CTX:-}
- LOKI_NGL=${LOKI_NGL:-}
# Accès par nom de domaine (reverse proxy) sans clé de pilotage : nom(s)
# autorisé(s), séparés par des virgules — sinon l'API répond 403.
- LOKI_TRUSTED_HOSTS=${LOKI_TRUSTED_HOSTS:-}
volumes:
# /data = LOKI_HOME : config, base bbolt, mémoire, workspace, modèles téléchargés
- ./data:/data
@@ -34,4 +37,8 @@ services:
- driver: nvidia
count: all
capabilities: ["gpu"]
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
ulimits:
memlock: -1
restart: unless-stopped
+39 -3
View File
@@ -1,6 +1,7 @@
#!/bin/sh
# Entrypoint du conteneur Loki : sème la configuration au premier démarrage,
# lance le moteur (supervision PID, sans systemd), puis l'UI au premier plan.
# Entrypoint du conteneur Loki : met à jour les binaires Loki, sème la
# configuration au premier démarrage, lance le moteur (supervision PID, sans
# systemd), puis l'UI au premier plan derrière la façade Rust (loki-rs).
set -eu
: "${LOKI_HOME:=/data}"
@@ -15,6 +16,33 @@ if ! grep -qs " $LOKI_HOME " /proc/self/mountinfo; then
echo "[entrypoint] Ajoute « -v /chemin/hote/data:/data » (docker run) ou le mapping volumes du docker-compose."
fi
# ── Mise à jour au démarrage ───────────────────────────────────────────────
# Le loki-rs DE L'IMAGE télécharge la dernière release « rolling » (binaires
# vérifiés par SHA-256) dans $LOKI_HOME/bin/<build>/ : un simple redémarrage
# du conteneur suffit à passer à la dernière version. C'est toujours celui de
# l'image qui s'en charge — un binaire publié défectueux ne peut donc pas
# empêcher le correctif suivant d'arriver. Jamais bloquant (réseau coupé,
# empreinte fausse : l'état précédent reste). LOKI_AUTO_UPDATE=0 le coupe.
/usr/local/bin/loki-rs self-update || true
# loki et loki-rs viennent TOUJOURS du même dossier : /data/bin/<build> s'il
# est plus récent que l'image et de même ABI, sinon l'image.
BIN_DIR=$(/usr/local/bin/loki-rs which-bin 2>/dev/null || true)
if [ -z "$BIN_DIR" ] || [ ! -x "$BIN_DIR/loki" ] || [ ! -x "$BIN_DIR/loki-rs" ]; then
BIN_DIR=/usr/local/bin
fi
echo "[entrypoint] binaires Loki : $BIN_DIR"
# Tous les appels « loki » ci-dessous visent le binaire choisi.
loki() { "$BIN_DIR/loki" "$@"; }
# Et `docker exec loki loki …` aussi : /usr/local/sbin passe AVANT
# /usr/local/bin dans le PATH, un lien y suffit sans toucher aux binaires de
# l'image (celui de loki-rs doit rester intact, c'est lui qui met à jour).
if [ "$BIN_DIR" != /usr/local/bin ]; then
ln -sf "$BIN_DIR/loki" /usr/local/sbin/loki 2>/dev/null || true
else
rm -f /usr/local/sbin/loki 2>/dev/null || true
fi
# Le chemin du moteur est imposé par l'image (llama-server précompilé de
# l'image officielle llama.cpp, dans /app) : on le (re)pose à chaque boot,
# une mise à jour de l'image ne doit pas laisser un BIN obsolète en base.
@@ -59,4 +87,12 @@ else
echo "[entrypoint] aucun MODEL configuré — télécharge un modèle depuis l'UI pour démarrer le moteur"
fi
exec loki web "${LOKI_WEB_PORT:-8090}"
# Façade Rust (loki-rs) sur le port public ; elle lance `loki web` sur
# 127.0.0.1:${LOKI_GO_PORT:-8091} et lui relaie tout. LOKI_FACADE=0 revient à
# l'ancien schéma (Go seul, directement sur le port public) — porte de sortie
# si la façade pose problème.
export LOKI_WEB_PORT="${LOKI_WEB_PORT:-8090}"
if [ "${LOKI_FACADE:-1}" = "0" ]; then
exec "$BIN_DIR/loki" web "$LOKI_WEB_PORT"
fi
exec "$BIN_DIR/loki-rs" serve
@@ -0,0 +1,122 @@
# Migration Rust lot 1 — façade + mise à jour au démarrage — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Mettre un binaire Rust `loki-rs` en frontal du conteneur (proxy vers le Go) et faire que le conteneur prenne les derniers binaires publiés à chaque démarrage.
**Architecture:** `loki-rs serve` écoute sur `LOKI_WEB_PORT` (8090), lance `loki web 8091` (Go, `LOKI_WEB_BIND=127.0.0.1`) en enfant supervisé et lui proxifie tout (HTTP, SSE, WebSocket). `loki-rs self-update` télécharge la release GitHub `rolling` dans `/data/bin/<build>/`, `loki-rs which-bin` dit à l'entrypoint quel dossier de binaires utiliser.
**Tech Stack:** Rust 2021 (tokio, hyper 1, hyper-util, http-body-util, reqwest rustls, sha2, serde_json), cible `x86_64-unknown-linux-musl` ; Go 1.25 existant ; GitHub Actions.
Spec : `docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
## Global Constraints
- Port public inchangé : 8090 (`LOKI_WEB_PORT`). Go interne : `127.0.0.1:8091` (`LOKI_GO_PORT`).
- Release : tag `rolling`, pré-release, assets `loki-linux`, `loki-rs-linux`, `SHA256SUMS`, `manifest.json` (uploadé en dernier).
- `manifest.json` = `{"build": <github.run_number>, "abi": <IMAGE_ABI>, "commit": "<sha>"}`.
- Source unique de l'ABI : fichier `IMAGE_ABI` à la racine (contenu `1`), copié dans l'image en `/etc/loki/IMAGE_ABI`. Build de l'image : `ENV LOKI_BUILD=<run_number>` (0 en build local).
- Les binaires de `/data/bin/<build>/` ne sont utilisés que si `abi == IMAGE_ABI` **et** `build > LOKI_BUILD`.
- `LOKI_AUTO_UPDATE=0` : aucune requête réseau. Le boot n'est jamais bloqué (code 0, timeouts bornés).
- La façade efface `X-Loki-Via` entrant ; transmet `Host`, `Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto` intacts ; retire les hop-by-hop ; ne bufferise rien.
- Go : `gofmt -l .` vide + `staticcheck ./...` propre. Rust : `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`.
- Git : jamais `git add -A` ni `git stash` ; commits en français, style narratif.
## File Structure
```
IMAGE_ABI ABI d'image (entier)
rust/Cargo.toml workspace
rust/loki-rs/Cargo.toml
rust/loki-rs/src/main.rs CLI : serve | self-update | which-bin | version
rust/loki-rs/src/proxy.rs proxy HTTP/SSE/WS vers le Go
rust/loki-rs/src/child.rs supervision de `loki web`
rust/loki-rs/src/update/mod.rs orchestration self-update + which-bin
rust/loki-rs/src/update/sums.rs parse SHA256SUMS
rust/loki-rs/src/update/state.rs release.json, décision, sélection du dossier
rust/loki-rs/tests/proxy.rs intégration façade
rust/loki-rs/tests/update.rs intégration self-update (faux serveur)
internal/loki/web_server.go LOKI_WEB_BIND
Dockerfile étape rustbuild, IMAGE_ABI, LOKI_BUILD
docker-entrypoint.sh self-update, which-bin, exec loki-rs serve
.github/workflows/ci.yml job rust
.github/workflows/docker-publish.yml LOKI_BUILD + job rolling
README.md section mise à jour au démarrage
```
---
### Task 1: Go — adresse d'écoute configurable
**Files:** Modify `internal/loki/web_server.go` (cmdWeb), Test `internal/loki/web_bind_test.go`
**Interfaces:** Produces `webBindAddr(port int) string` — `LOKI_WEB_BIND` (défaut `0.0.0.0`) + `:port`.
- [ ] Test : `t.Setenv("LOKI_WEB_BIND","")` → `"0.0.0.0:8090"` ; `"127.0.0.1"` → `"127.0.0.1:8091"`.
- [ ] `go test ./internal/loki -run TestWebBindAddr` → FAIL (indéfini).
- [ ] Implémenter, l'utiliser dans `cmdWeb` à la place de `fmt.Sprintf("0.0.0.0:%d", port)`.
- [ ] Test PASS, `gofmt -l .` vide, staticcheck propre. Commit.
### Task 2: Workspace Rust + parse SHA256SUMS + état d'installation
**Files:** Create `rust/Cargo.toml`, `rust/loki-rs/Cargo.toml`, `src/main.rs` (squelette CLI), `src/update/{mod,sums,state}.rs`, `.gitignore` (`rust/target/`).
**Interfaces (produits) :**
- `sums::parse(text:&str) -> HashMap<String,String>` (nom → hex minuscule ; accepte `*nom`, ignore lignes vides/invalides).
- `state::Manifest { build:u64, abi:u32, commit:String }` (serde).
- `state::Installed` = Manifest écrit dans `<dir>/release.json`.
- `state::read_installed(dir:&Path) -> Option<Manifest>`.
- `state::select_dir(data_bin:&Path, image_dir:&Path, image_build:u64, image_abi:u32) -> PathBuf` : `<data_bin>/<build>` si release.json valide, abi égal, build > image_build, et `loki` + `loki-rs` présents dedans ; sinon `image_dir`.
- `state::needs_update(m:&Manifest, image_build:u64, image_abi:u32, installed:Option<&Manifest>) -> Decision` avec `enum Decision { Install, UpToDate, AbiMismatch }`.
- [ ] Tests unitaires : parse (2 espaces, `*`, majuscules → minuscules, ligne cassée ignorée) ; `needs_update` (abi ≠ → AbiMismatch ; build ≤ image → UpToDate ; build ≤ installé → UpToDate ; sinon Install) ; `select_dir` (aucun fichier → image ; abi ≠ → image ; build ≤ image → image ; binaire manquant → image ; ok → data dir).
- [ ] `cargo test` FAIL puis implémentation, PASS, fmt + clippy. Commit.
### Task 3: self-update (téléchargement vérifié, installation atomique)
**Files:** `src/update/mod.rs`, `tests/update.rs`
**Interfaces :** `update::run(cfg:&UpdateConfig) -> Outcome` (jamais d'erreur remontée au CLI : journalise et rend `Outcome::{Installed(u64), UpToDate, AbiMismatch, Failed(String), Disabled}`) ; `UpdateConfig { base_url, data_bin, image_build, image_abi, enabled, timeout }` construit par `UpdateConfig::from_env()` (`LOKI_UPDATE_URL` défaut `https://github.com/R0m1k3/Loki/releases/download/rolling`, `LOKI_HOME/bin`, `LOKI_BUILD`, `/etc/loki/IMAGE_ABI` puis `LOKI_IMAGE_ABI`, `LOKI_AUTO_UPDATE`).
Algorithme : manifeste → décision → `SHA256SUMS` → télécharge `loki-linux`, `loki-rs-linux` dans `<data_bin>/.tmp-<build>/` → vérifie chaque hash → chmod 755 (unix) → renomme en `loki`, `loki-rs` → écrit `release.json` → renomme le dossier en `<data_bin>/<build>` → écrit `<data_bin>/release.json` (tmp + rename) → supprime les autres `<n>/` et `.tmp-*`. Toute erreur : supprime `.tmp-<build>`, `Failed`.
- [ ] Tests d'intégration (faux serveur hyper sur 127.0.0.1:0 servant des fichiers en mémoire) : installation OK (fichiers présents, release.json, select_dir pointe dessus) ; hash faux → rien installé, ancien état intact ; abi ≠ → rien téléchargé hormis le manifeste ; serveur absent → Failed, pas de panic ; `enabled=false` → Disabled sans requête ; seconde exécution → UpToDate et nettoyage des anciens builds.
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
### Task 4: Façade proxy
**Files:** `src/proxy.rs`, `tests/proxy.rs`
**Interfaces :** `proxy::serve(listener: tokio::net::TcpListener, backend: SocketAddr, shutdown: impl Future) -> io::Result<()>`.
Comportement : hyper http1 + `with_upgrades` ; chaque requête → client hyper-util vers `backend` (URI `http://backend{path_and_query}`), Host d'origine conservé ; retire hop-by-hop (`connection`, `keep-alive`, `proxy-connection`, `te`, `trailer`, `transfer-encoding`, `upgrade`, + noms listés dans `Connection`) et `x-loki-via` ; si requête d'upgrade (`Connection: upgrade` + `Upgrade`), garde `Connection: upgrade` + `Upgrade`, attend 101, puis `copy_bidirectional` entre les deux côtés upgradés ; corps en streaming dans les deux sens ; erreur de connexion → 502 `text/plain` « Loki (Go) redémarre — réessaie dans un instant ».
- [ ] Tests (faux backend hyper) : GET relayé avec Host/Authorization/Origin/X-Forwarded-Proto intacts et `X-Loki-Via` absent côté backend ; POST corps relayé ; SSE : backend envoie 1er événement puis attend 500 ms — le client reçoit le 1er avant 300 ms ; WebSocket écho via tokio-tungstenite ; backend arrêté → 502.
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
### Task 5: Supervision du Go + commande `serve`
**Files:** `src/child.rs`, `src/main.rs`
**Interfaces :** `child::Supervisor::spawn(go_bin:PathBuf, port:u16) -> Supervisor` (relance avec backoff 1 s → 30 s, remis à 1 s après 60 s de vie ; stdin null, stdout/stderr hérités ; env `LOKI_WEB_BIND=127.0.0.1`) ; `Supervisor::shutdown(self, grace:Duration)` (SIGTERM unix / kill windows, puis kill après `grace`).
`main serve` : go_bin = `LOKI_GO_BIN` sinon `loki` à côté de l'exe courant ; attend jusqu'à 30 s que `127.0.0.1:LOKI_GO_PORT` accepte (sinon ouvre quand même, 502 en attendant) ; `proxy::serve` sur `0.0.0.0:LOKI_WEB_PORT` ; SIGTERM/SIGINT → arrêt du proxy + `shutdown(10 s)`.
`main which-bin` imprime `select_dir(...)` ; `main version` imprime version crate + `LOKI_BUILD`.
- [ ] Test unitaire `go_bin` par défaut ; test du backoff (fonction pure `next_backoff(prev, lived) -> Duration`).
- [ ] Test manuel local : `loki-rs serve` avec un `loki.exe` construit → `curl localhost:8090/` renvoie l'UI.
- [ ] Commit.
### Task 6: Image, entrypoint, CI, release rolling, doc
**Files:** `IMAGE_ABI`, `Dockerfile`, `docker-entrypoint.sh`, `.github/workflows/ci.yml`, `.github/workflows/docker-publish.yml`, `README.md`.
- Dockerfile : étape `FROM rust:1-alpine AS rustbuild` (`apk add musl-dev`, `cargo build --release --locked`), copie `/usr/local/bin/loki-rs` ; `ARG LOKI_BUILD=0` → `ENV LOKI_BUILD` ; `COPY IMAGE_ABI /etc/loki/IMAGE_ABI`.
- Entrypoint : `loki-rs self-update` (best-effort) ; `BIN_DIR=$(loki-rs which-bin)` ; `LOKI="$BIN_DIR/loki"` pour tous les appels config/start ; `exec "$BIN_DIR/loki-rs" serve`.
- CI : job `rust` (fmt, clippy, test sur ubuntu).
- docker-publish : `LOKI_BUILD=${{ github.run_number }}` en build-arg ; job `rolling` (contents: write, concurrency `rolling`) : build Go linux CGO=0, build Rust musl (`rustup target add x86_64-unknown-linux-musl`, `musl-tools`), SHA256SUMS, manifest, `gh release create rolling --prerelease` si absent, upload `--clobber` binaires+sums puis manifest.
- README : section « Mise à jour au démarrage » (comportement, `LOKI_AUTO_UPDATE=0`, ABI → pull).
- [ ] `sh -n docker-entrypoint.sh`, YAML relu, commit.
## Self-review
Spec couvert : façade (T4/T5), X-Loki-Via (T4), bind Go (T1), rolling + manifeste + ABI (T2/T3/T6), repli et boot non bloquant (T3/T6), désactivation (T3), tests (T2–T4), CI (T6). Écart assumé vs spec : ordre des versions par `build` (run_number) et non par commit, et installation dans `/data/bin/<build>/` (bascule atomique par `release.json`).
@@ -0,0 +1,150 @@
# Migration de Loki vers Rust — lot 1 : façade Rust + mise à jour au démarrage
Date : 2026-10-13
Statut : validé (design), à planifier
## Contexte
Loki est aujourd'hui un monobinaire Go (`internal/loki`, ~65 000 lignes hors
tests) qui sert une UI embarquée (~26 000 lignes JS/CSS/HTML) et pilote
llama.cpp, Strata, MCP, le mode Code, etc. L'objectif à terme est de le
réécrire en Rust. Une réécriture d'un bloc ne livrerait rien pendant des
semaines : la migration est **progressive**, par lots, avec Rust en production
dès le premier lot.
Second besoin : aujourd'hui, un `docker restart` relance la même image. Le
conteneur doit prendre la dernière version de Loki **au prochain démarrage**,
sans `docker compose pull` manuel.
## Architecture de cohabitation (« strangler fig »)
```
:8090 ──► loki-rs (Rust, axum/tokio)
├─ routes portées → traitées en Rust
└─ tout le reste → proxy HTTP / SSE / WebSocket
→ loki web (Go) sur 127.0.0.1:8091
```
- Nouveau workspace Cargo dans `rust/`, binaire `loki-rs`, compilé en statique
(`x86_64-unknown-linux-musl`) : indépendant de la glibc de l'image.
- Go écoute uniquement en local sur 8091 (nouvelle option d'adresse d'écoute
de `loki web`). Le port public reste 8090 : compose et reverse proxy
inchangés.
- Au lot 1, **aucune route n'est portée** : la façade proxifie tout. Elle sert
de socle aux lots suivants.
- Authentification (clé de pilotage, `LOKI_TRUSTED_HOSTS`) : reste à Go tant
qu'il sert la route. Go ne fonde aucune décision d'accès sur l'IP source
(vérifié : ni `RemoteAddr` ni `X-Forwarded-For` dans `internal/loki`), donc
passer par 127.0.0.1 n'élargit rien. La façade transmet `Host`,
`Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto`
**sans les altérer**, et retire seulement les en-têtes hop-by-hop.
- **Sécurité** : la façade **efface `X-Loki-Via`** de toute requête entrante.
Cet en-tête, posé par le tunnel du relais, dispense du contrôle d'hôte
anti-DNS-rebinding (`crossSiteReject`). Aujourd'hui l'écoute HTTP directe de
Go ne l'efface pas : sans clé de pilotage, une page en DNS rebinding peut le
forger. La façade ferme ce trou dans le conteneur ; un correctif Go séparé
le ferme pour `loki web` hors conteneur. Test dédié.
- Flux : pas de timeout d'écriture, pas de bufferisation des réponses
`text/event-stream` (chat SSE, `/v1` en streaming), upgrade WebSocket relayé
(`/api/node/ws` et autres). Pas de recompression : Go gère déjà gzip.
- Le relais `loki link` reste entièrement côté Go jusqu'à son propre lot.
### Démarrage et arrêt
`loki-rs serve` lance `loki web 8091` (Go) comme processus enfant, attend
qu'il réponde, puis ouvre 8090. Si l'enfant meurt, la façade le relance
(backoff borné) et répond 502 avec un message clair entre-temps. Sur SIGTERM,
la façade transmet le signal à l'enfant et attend sa fin (moteur llama-server
arrêté proprement par Go, comme aujourd'hui).
## Mise à jour au démarrage
### Publication (CI)
À chaque push sur `main`, en plus de l'image GHCR, un workflow publie la
release GitHub **`rolling`** (pré-release, assets écrasés) :
- `loki-linux` — binaire Go (déjà `CGO_ENABLED=0`)
- `loki-rs-linux` — binaire Rust musl
- `SHA256SUMS` — format `sha256sum` (celui que `loki update` sait déjà lire)
- `manifest.json` — `{"commit": "<sha>", "image_abi": <entier>}`
La release `rolling` n'est jamais marquée `latest` : `loki update` (machines
non conteneurisées) continue de suivre les tags `vX.Y.Z`.
### ABI d'image
Le Dockerfile déclare `ENV LOKI_IMAGE_ABI=1`. On l'incrémente (Dockerfile et
source du manifeste dans le même commit) quand le code exige un changement de
l'image : nouvelle dépendance système, Python/Node, chemin du moteur, etc.
Un binaire publié avec une ABI différente de celle de l'image n'est **jamais**
installé : il ne pourrait pas tourner correctement.
### Au boot (`loki-rs self-update`)
Appelé par `docker-entrypoint.sh`, avec le `loki-rs` de l'image :
1. `LOKI_AUTO_UPDATE=0` → ne fait rien.
2. Télécharge `manifest.json` (timeout global 20 s). Erreur réseau → étape 6.
3. `image_abi` ≠ `LOKI_IMAGE_ABI` → journal :
« nouvelle version nécessitant une nouvelle image : docker compose pull »,
étape 6.
4. `commit` identique à `/data/bin/VERSION` → rien à faire, étape 6.
5. Télécharge les deux binaires en `/data/bin/*.part`, vérifie chacun contre
`SHA256SUMS`, `chmod +x`, renomme atomiquement, écrit `VERSION` en dernier.
Toute erreur → `.part` supprimés, rien remplacé.
6. Fin. Code de sortie 0 dans tous les cas : le boot n'est jamais bloqué.
L'entrypoint choisit ensuite les binaires : `/data/bin/loki` et
`/data/bin/loki-rs` s'ils existent, sont exécutables **et** que
`/data/bin/ABI` égale `LOKI_IMAGE_ABI` ; sinon ceux de l'image. Les deux
binaires viennent toujours de la même source (jamais un Go de l'image avec un
Rust de `/data/bin`). Le chemin Go choisi est passé à la façade
(`LOKI_GO_BIN`) et utilisé pour les appels `loki config` de l'entrypoint.
Limite assumée : seuls les binaires Loki se mettent à jour ainsi ; llama.cpp
se met à jour via l'UI (existant), le reste de l'image via un pull signalé par
l'ABI.
## Organisation du code Rust
```
rust/
Cargo.toml workspace
loki-rs/
src/main.rs CLI : serve | self-update | version
src/proxy.rs façade HTTP/SSE/WebSocket
src/child.rs supervision du processus Go
src/update/ manifeste, téléchargement, SHA256SUMS, installation
```
Dépendances : `tokio`, `axum`, `hyper`/`hyper-util`, `reqwest` (rustls,
sans OpenSSL — musl), `sha2`, `serde`/`serde_json`, `clap`.
## Tests
- Unitaires : parse de `SHA256SUMS` (entrées `*nom`, nom absent), décision
d'installation (ABI différente, même commit, `LOKI_AUTO_UPDATE=0`),
installation atomique (échec au milieu → rien remplacé).
- Intégration : façade devant un faux backend axum —
SSE reçu morceau par morceau (pas en bloc), WebSocket écho,
en-têtes `Host`/`Authorization`/`Origin` transmis intacts, `X-Loki-Via`
entrant effacé, 502 si backend absent.
- Mise à jour : faux serveur de release local (URL de base injectable par
variable d'environnement de test).
- CI : `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test` dans un
job dédié ; la CI Go (gofmt + staticcheck + tests) reste inchangée.
## Feuille de route des lots suivants (hors périmètre de ce spec)
| Lot | Contenu |
|---|---|
| 2 | Fichiers statiques de l'UI ; lecture de la config via une API interne Go |
| 3 | Supervision de llama-server |
| 4 | Client LLM, `/v1` OpenAI, flux chat SSE |
| 5 | Outils de l'agent, MCP, mode Code/LSP |
| 6 | Modèles et téléchargements, Strata, optimiseur, bench |
| 7 | Mémoire, tâches, dictée, relais, CDP |
| 8 | Store bbolt → redb, retrait de Go |
Chaque lot aura son propre spec, plan et PR.
@@ -0,0 +1,74 @@
# Migration Rust — lot 2 : UI statique, authentification, `/api/ping`
Date : 2026-10-13 · Statut : validé · Prérequis : lot 1 (façade `loki-rs`).
## But
Faire passer en Rust les premières routes réelles, et surtout poser le socle
dont toutes les routes `/api/*` suivantes auront besoin : l'authentification.
## Périmètre
1. **UI statique servie par Rust**, embarquée dans le binaire à la
compilation depuis `internal/loki/ui/` (même source que le `go:embed`) :
| Route | Content-Type | Cache-Control | Autre |
|---|---|---|---|
| `/`, `/index.html` | `text/html; charset=utf-8` | `no-store, max-age=0` | |
| `/marked.min.js` | `application/javascript` | `public, max-age=86400` | |
| `/sw.js` | `application/javascript` | `no-store, max-age=0` | `Service-Worker-Allowed: /` |
| `/manifest.webmanifest` | `application/manifest+json` | `public, max-age=3600` | |
| `/fonts/<nom>.woff2` | `font/woff2` | `public, max-age=31536000, immutable` | |
GET et HEAD seulement (les autres méthodes restent relayées au Go).
Compression gzip si le client l'accepte, sauf polices (comme
`web_gzip.go`) ; `Vary: Accept-Encoding` sur les types compressibles.
Version compressée calculée une fois, à la première demande.
2. **API interne Go** `GET /internal/v1/auth` → `{"web_key": "…", "version": "…"}`.
- Servie **uniquement sur l'écoute TCP directe** de `loki web`
(enveloppe autour du mux dans `cmdWeb`) : le tunnel du relais sert le mux
nu et ne la voit jamais.
- Exige l'en-tête `X-Loki-Internal` égal à `LOKI_INTERNAL_TOKEN` (≥ 32
caractères, comparaison en temps constant). Jeton absent, trop court ou
faux → 404 (rien ne révèle l'existence de la route).
- Base illisible → 503 : la façade fermera l'accès (jamais ouvert par
défaut sur une panne d'E/S, même règle que `requireWebAuth`).
- La façade tire un jeton aléatoire de 32 octets à chaque démarrage et le
passe au Go par l'environnement.
3. **Authentification de pilotage en Rust** (`auth.rs`), port à l'identique de
`requireWebAuth` + `crossSiteReject` + `localHostName` + `checkBearer` :
- clé relue à **chaque requête** (API interne) : un changement de clé prend
effet immédiatement ;
- API interne en erreur (Go absent, 503, réponse illisible) → 503
`{"error":"configuration illisible — réessaie dans un instant"}` ;
- refus site tiers / hôte non local sans clé → 403 `{"error":"<raison>"}`
(mêmes messages que le Go) ;
- Bearer absent ou faux → 401 `{"error":"non autorisé"}` +
`WWW-Authenticate: Bearer realm="loki"` ;
- `X-Loki-Via` n'est jamais pris en compte côté Rust (effacé à l'entrée,
le trafic du tunnel ne traverse pas la façade).
4. **`/api/ping` en Rust** : `{"ok":true,"service":"loki","version":"<version Go>"}`
après authentification — première route `/api/*` servie par Rust.
5. **Durcissement de la façade** : `/internal/*` venant de l'extérieur → 404 ;
en-tête `X-Loki-Internal` entrant effacé avant relais.
## Hors périmètre
Toutes les autres routes `/api/*`, `/v1`, le flux chat : lots suivants.
## Tests
- Go : route interne (sans jeton → 404, mauvais jeton → 404, bon jeton → 200
avec clé et version, autres chemins relayés au mux).
- Rust : cas de `TestCrossSiteReject` repris un pour un ; `local_host_name`
(IP, IPv6 entre crochets, nom sans point, suffixes locaux, hôte de
confiance) ; Bearer ; statiques (types, cache, gzip, HEAD, police inconnue
→ relayée) ; intégration façade + faux backend interne : ping 200 sans clé,
401 avec clé sans Bearer, 200 avec bon Bearer, 503 si l'API interne tombe,
403 rebinding, `/internal/v1/auth` depuis l'extérieur → 404.
- Bout en bout local : vrai `loki` Go derrière la façade, `loki set-web-key`,
ping avec et sans clé.
@@ -0,0 +1,49 @@
# Migration Rust — lot 3 : supervision du moteur
Date : 2026-10-14 · Statut : livré · Prérequis : lots 1-2.
## Découpage
Le « moteur » en conteneur, c'est deux couches de taille très différente :
1. **La supervision du processus** (~350 lignes Go : `sys_service_container.go`,
`sys_proctree_linux.go`) — lancer `loki serve` détaché, fichier PID,
journal, arrêter tout l'arbre (Strata compris) et attendre que la VRAM
soit rendue.
2. **La ligne de commande** de llama-server / Strata (~9 000 lignes :
`backend_serve*.go`, presets, MoE, spéculatif, threads, cache…), là où
s'applique la règle « ne jamais dénaturer les modèles ».
Le lot 3 porte la couche 1. La couche 2 reste au Go (`loki serve`) et fera
l'objet d'un lot dédié, parce que chaque option y a été réglée contre un
défaut mesuré.
## Ce qui change
- `loki-rs engine start|stop|restart|status` : port à l'identique de la
supervision Go — chef de session (`setsid`), fichier PID et journal aux
**mêmes chemins** (`$LOKI_HOME/<service>.pid|.log`), contrôle à 2 s avec
les 20 dernières lignes du journal en cas d'échec, arrêt de l'arbre
(session, groupe, filiation ; SIGTERM, 4 s, SIGKILL, 20 s au plus), mêmes
messages.
- Le Go garde ses garde-fous (optimisation en cours via `serviceAction`,
preset externe) puis délègue au `loki-rs` voisin de son binaire.
`loki logs`, `enable`/`disable` restent au Go.
- Porte de sortie : `LOKI_ENGINE_SUPERVISOR=go`, ou absence de `loki-rs`.
- Différence assumée : le contrôle « démarré ? » regarde si l'enfant est
sorti (`try_wait`), pas le signal 0 — un enfant mort reste un zombie, qui
répond encore au signal 0.
- L'arrêt exclut de l'arbre `loki-rs` lui-même ET son parent (le Go qui l'a
appelé), comme le Go excluait son propre PID.
## Tests
- Rust (toutes plateformes) : `/proc/<pid>/stat` avec parenthèses dans le
nom, arbre par session/groupe/filiation avec exclusion de l'appelant.
- Rust (Linux, CI) : faux `loki serve` qui lance un enfant dans sa session et
un « moteur » dans SA PROPRE session (comme Strata) — démarrage, « déjà
démarré », arrêt de tout l'arbre, redémarrage, échec avec journal, PID
nettoyé.
- Go (Linux, CI) : choix du `loki-rs` voisin (absent, non exécutable,
`LOKI_ENGINE_SUPERVISOR=go`), appel réel avec l'environnement attendu,
erreur rendue = dernière ligne d'erreur du superviseur.
@@ -0,0 +1,93 @@
# Migration Rust — lot 6 : le cœur chat
Date : 2026-10-14 · Statut : **livré** (étapes 1 à 6 ; défaut Rust depuis le 2026-10-15, `LOKI_CHAT=go` pour revenir au Go) ·
Prérequis : lots 1-5 (façade, auth, store via API interne, `/v1`).
## Pourquoi c'est un lot à part
Le chat est le cœur de Loki et le seul chemin qu'on ne peut pas casser un
soir de déploiement. ~17 700 lignes Go (`chat_*.go` 11 491, `llm_*.go`
6 245), dont une fonction de 1 469 lignes (`runChatLoop`,
`llm_client.go:1279`) qui mêle protocole du moteur, reprises sur erreur,
compactage en cours de tour et exécution des outils.
## Carte (HEAD b98ee5c)
### Surface HTTP
Toutes derrière `requireWebAuth` (déjà portée).
| Route | Handler | Rôle |
|---|---|---|
| `/api/chat` | `handleChat` web_chat.go:316 | **SSE** : rejoue le journal puis le suit en direct |
| `/api/chat/send` | `handleChatSend` :178 | démarre un tour ou le met en file (409 occupé, 503 pas prêt) |
| `/api/chat/stop`, `/reset`, `/state`, `/compact`, `/tool-result` | web_chat.go | contrôle et lecture |
| `/api/chat/mode`, `/criteria` | code_mode.go | mode Code |
| `/api/chat/export`, `/upload`, `/files*`, `/file*`, `/image`, `/cushot` | divers | pièces jointes, espace de travail, aperçu navigateur |
| `/api/conversations{,/new,/switch,/rename,/delete}` | web_chat.go | index des discussions |
| `/api/e2e/chat` | relay_e2e.go:143 | même flux, chiffré AES-GCM par événement (tunnel) |
Trame SSE : `data: {"choices":[{"delta":…}]}`, battement `: ping` toutes les
4 s, regroupement à 128 Kio ou 256 événements.
### État en mémoire
- `conv *Conversation` (chat_conversation.go:114) : `Messages` (vue du
modèle), `Log []LogEvent{Seq,TS,Delta}` (journal d'affichage, **seule voie
vers l'UI**, via `appendDelta`), `Seq`, `CtxUsed`, `Generating`, `cancel`,
`epoch`, file d'attente, tâche en cours. Un `mu` + `cond`.
- `Generating` est un **verrou global du moteur** partagé avec les tâches
planifiées (`RunAutonomous`), le bench et l'optimiseur (`measureLease`) et
quatre sondes `isGenerating`.
- Persistance : `snapshot()` sérialise Messages ET Log dans un seul JSON,
**chiffré** (`memEncoderNow`, clé en mémoire du Go après déverrouillage),
écrit par un écrivain unique (`persistQ`) en une transaction bbolt —
bucket `chat`, clés `conv:<id>`, `index`, `active`.
### Boucle d'un tour
`handleChatSend` → `EnqueueOrStart` → `StartTurn` → `go generate` →
`turnView` (prompt système, outils, MCP, mémoire, instantané projet) →
`runChatTools` → `runChatLoop` (payload, slot, requête au moteur, analyse du
flux, reprises, compactage réactif, **switch des outils inline**
llm_client.go:2363-2549, compactage en cours de tour) → retour dans
`generate` (validation mode Code `codeVerifyLoop`, compactage de fin de
tour, persistance, file suivante, préchauffage).
## Coupures possibles
| | Coupure | Ce qui traverse | Risque |
|---|---|---|---|
| A | Journal + SSE en Rust, boucle au Go | flux de deltas Go→Rust, epoch, reset | Log persisté DANS le même blob chiffré que Messages |
| B | Client LLM en Rust (protocole, flux, reprises) | appel d'outil Go en RPC, porte du moteur, compactage | fonction de 1 469 lignes, appels récursifs (sous-agents, vérificateur) |
| C | Machine d'état de la conversation en Rust | turnView, compactage, outils, persistance en RPC | verrou `Generating` partagé avec tâches/bench/optimiseur |
| D | Sessions + persistance | — | écrivain unique bbolt, chiffrement, ordre des résultats d'outils |
## Stratégie proposée
Pas de bascule partielle de la discussion de l'utilisateur : le cœur chat
est réécrit **en entier** côté Rust, derrière `LOKI_CHAT=rust` (Go par
défaut), puis comparé au Go sur des conversations rejouées avant de devenir
le défaut. Étapes, chacune livrée et testée :
1. **Go, sans changement de comportement** : extraire le switch des outils en
`execTool(ctx, name, args, caps) → résultat` et l'exposer en API interne
(`/internal/v1/tool/exec`), ainsi que le chiffrement des blobs
(`/internal/v1/crypt/seal|open`, la clé reste au Go) et le bail du moteur
(`Generating` → `/internal/v1/engine/lease`).
2. **Rust, bibliothèque** : protocole du moteur (payload OpenAI, analyse SSE
llama.cpp/Strata, reasoning, appels d'outils natifs et textuels, effort,
reprises réseau), testé contre des flux enregistrés.
3. **Rust, conversation** : Messages + Log + epoch + file + SSE `/api/chat`
(rejeu, pagination `historyCut`, `coalesceReplay`, `compactLog`),
persistance au même format (`conv:<id>`, chiffré via l'étape 1).
4. **Rust, boucle** : `runChatLoop` réécrit en machine d'état explicite,
outils exécutés par le Go (étape 1), compactage porté.
5. **Parité** : rejeu de conversations réelles exportées, comparaison des
journaux produits (Go vs Rust) avec un faux moteur déterministe.
6. **Bascule** `LOKI_CHAT=rust` par défaut ; outils, MCP, mémoire, mode Code
portés ensuite (lot 7) en retirant les appels au Go un par un.
## Questions ouvertes
- Chiffrement : port du format (`memEncoderNow`) en Rust à l'étape 3, ou
RPC au Go jusqu'au lot 9 ?
- Tunnel du relais (`/api/e2e/chat`) : reste au Go jusqu'au portage du
relais ; tant que `LOKI_CHAT=rust`, le flux chiffré doit lire le journal
Rust (API interne inverse) — à concevoir.
@@ -0,0 +1,44 @@
# Lot 10 — réglages partagés tenus par la façade
## Contexte
Quatre routes ne font que lire et écrire des réglages en base, partagés entre
tous les appareils :
| Route | Port de | Ce qu'elle règle |
|---|---|---|
| `GET\|POST /api/prefs` | `web_prefs.go` | thème, affichage, prénom (24 caractères, sans caractère de contrôle), avatars (liste fermée) |
| `GET\|POST /api/sysprompt` | `web_sysprompt.go` | prompt système du preset actif (`state/sysprompt:<id>`), repli sur le global (`state/sysprompt`) |
| `GET\|POST /api/reasoning-effort` | `handleReasoningEffort` | `REASONING_EFFORT` de la configuration vivante (liste blanche) |
| `GET\|POST /api/oai/public` | `web_public_url.go` | adresse publique normalisée en « schéma://hôte[:port] » |
Lot 10 : la façade les sert quand `LOKI_SETTINGS=rust` (`settings.rs`,
expérimental ; sans la variable rien ne change).
Les valeurs restent dans la base du Go, aux mêmes clés. Le Go les relit là
où il s'en sert (tour de chat, accès OpenAI) : son cache de configuration se
périme sur l'horodatage du fichier de base, qu'une écriture de la façade
modifie. `facade_settings.go` remet les routes du tunnel à la façade.
## Écarts assumés
- Message d'un corps JSON syntaxiquement illisible : celui de serde. Une
valeur du mauvais type (`{"text":5}`) garde le message d'`encoding/json`.
- Préférences réécrites clé par clé au lieu d'une transaction.
- Port nommé dans l'adresse publique (`:https`) refusé : le Go le résolvait
par `/etc/services`.
## Parité
`internal/loki/parity_settings_test.go` tire du Go :
- 19 adresses publiques normalisées ou refusées ;
- 11 valeurs d'intensité ;
- 29 appels de route, avec le contenu de la base après chacun :
- fusion des préférences (valeurs refusées, prénom tronqué, vides effacés) ;
- prompt par preset avec repli, effacement, méthode refusée ;
- intensité acceptée ou refusée ;
- adresse publique.
`tests/settings.rs` rejoue le tout par la façade. Une mutation (prénom
tronqué à 25 caractères) fait échouer le rejeu.
@@ -0,0 +1,63 @@
# Lot 11 — fichiers de la discussion tenus par la façade
## Contexte
Les fichiers rangés par discussion vivaient dans le Go :
- `web_upload.go` : dépôt, téléchargement, image ;
- `web_files.go` : panneau Fichiers ;
- `web_files_zip.go` : archive ;
- `web_thumb.go` et `web_upload_orient.go` : vignettes ;
- `chat_convfiles.go` : chemins.
Lot 11 : la façade les sert quand `LOKI_FILES=rust` (`convfiles.rs`,
expérimental ; sans la variable rien ne change).
| Route | Rôle |
|---|---|
| `POST /api/chat/upload` | dépôt en morceaux base64 (8 Mio), fichier de 1 Gio au plus, espace disque vérifié, `.part` renommé à la fin sous un nom sûr et unique |
| `GET /api/chat/files` | panneau : dossiers d'abord puis du plus récent au plus ancien, tailles récursives (liens non suivis), vue « hors discussion » |
| `POST /api/chat/file/delete` | suppression ; refus de la racine et du dossier des discussions |
| `GET /api/chat/file` | fiche, tranche base64 (tunnel E2E) ou brut en pièce jointe (plage d'octets) |
| `GET /api/chat/image` | type reniflé sur le contenu, en ligne sous une CSP fermée ; vignette JPEG `thumb=<px>` redressée (EXIF) et réduite par moyennage |
| `GET /api/chat/files/zip` | archive construite à la fiche, puis servie en tranches ou en brut |
Le dossier de la discussion active reste décidé par le Go : la façade le lui
demande à chaque requête par la nouvelle route `/internal/v1/conv/workspace`
(`{dir, root, id, title}`). Il connaît la discussion active, qu'il tienne la
conversation ou que ce soit la façade. Les liens des anciens messages
(`captures/<id>/…`, `uploads/…` à la racine) restent résolus comme avant.
`facade_files.go` remet les routes du tunnel à la façade. Il ne vide plus les
`.part` d'envois au démarrage : la façade le fait au sien.
## Écarts assumés
- Octets des vignettes JPEG : l'encodeur diffère. Les dimensions, le
redressement et la réduction (algorithme porté pixel pour pixel) sont les
mêmes.
- Horodatage des entrées `.zip` en UTC.
- Une seule plage d'octets par requête de téléchargement : le Go en acceptait
plusieurs, en multipart.
## Parité
`internal/loki/parity_files_test.go` tire du Go :
- 10 noms de dépôt et 6 slugs ;
- 6 en-têtes EXIF ;
- les 8 orientations d'une image de 5×3 et 4 réductions, pixel pour pixel ;
- 10 reniflages ;
- 42 appels de route sur une arborescence fixe, dates comprises :
- panneau, téléchargement, image et vignette ;
- dépôt en un morceau, en deux morceaux, envoi expiré, fichier vide,
base64 illisible ;
- suppressions ;
- archive de la discussion et « hors discussion », contenu comparé
entrée par entrée ;
- les fichiers restants après chaque POST.
`tests/convfiles.rs` rejoue le tout par la façade, plus une plage d'octets,
un lien vers l'extérieur (refusé, non compté) et le nom d'archive tiré du
titre. Deux mutations (ordre du panneau, suffixe des doublons) font échouer
le rejeu.
@@ -0,0 +1,47 @@
# Lot 12 — notifications Web Push tenues par la façade
## Contexte
Les notifications « réponse prête » vivaient dans le Go : `push.go`,
`web_push.go` et webpush-go. Lot 12 : la façade les tient quand
`LOKI_PUSH=rust` (`push.rs`, expérimental ; sans la variable rien ne change).
| Route | Rôle |
|---|---|
| `GET /api/push/key` | clé publique VAPID, générée au premier appel puis stable |
| `POST /api/push/subscribe` | enregistre un abonnement (idempotent par endpoint, clés mises à jour) |
| `POST /api/push/unsubscribe` | le retire |
| `POST /internal/facade/v1/push/send` | (le Go, jeton interne) pousse une notification à tous les abonnés, en tâche de fond |
Les envois du Go passent tous par `sendPushToAll` : fin de tour du chat Go,
fin de tour tenu par la façade (`/internal/v1/turn/done`), fin de tâche. Ils
sont délégués à la façade. Clés et abonnements restent en base aux mêmes
clés (`push_vapid_priv`, `push_vapid_pub`, `push_subs`).
Chiffrement RFC 8291 (`aes128gcm`), enregistrement complété à 4096 octets
comme webpush-go. Signature VAPID RFC 8292 (JWT ES256, 12 h, sujet
« mailto: »). TTL de 120 s, urgence haute. Un abonnement refusé pour de bon
(404/410) est purgé. La cryptographie passe par ring, déjà présent via
rustls. Une façade construite sans TLS répond que les notifications sont
indisponibles.
## Écarts assumés
- Messages des journaux d'envoi au format des crates Rust.
## Parité
- `internal/loki/parity_push_test.go` tire du Go :
- les durées du corps de notification ;
- onze appels de route (clé, abonnements valides et refusés, mise à jour
des clés, désabonnements), avec la liste en base après chacun.
`tests/push.rs` rejoue le tout par la façade. La liste en base est
comparée à l'octet près, et la clé reste la même d'un appel à l'autre.
- Le chiffrement reproduit l'exemple de l'annexe A de la RFC 8291 à l'octet
près.
- Un envoi de bout en bout vers un faux service de push vérifie :
- les en-têtes ;
- la signature VAPID, contrôlée avec la clé rangée ;
- le corps, déchiffré côté navigateur avec la clé de l'abonnement ;
- l'abonnement en 410, purgé.
@@ -0,0 +1,117 @@
# Lot 13 — mémoire tenue par la façade
## Contexte
La mémoire de Loki vivait dans le Go :
- `chat_memory.go`, `mem_index.go`, `mem_io.go` et `mem_fsutil.go` pour les pages ;
- `moveMemPage` (`projects.go`) pour le déplacement entre projets ;
- `tracker.go` et `web_tracker.go` pour les trackers ;
- les routes du mode agent.
Lot 13 : la façade tient ces routes quand `LOKI_MEM=rust` (`memory.rs`,
expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `GET /api/mem?name=` | contenu d'une page (modèle de page neuve sans nom) |
| `POST /api/mem/save` | crée, écrase ou renomme une page ; l'index suit |
| `POST /api/mem/delete` | supprime une page ; sa ligne d'index part avec elle |
| `POST /api/mem/move` | déplace une page vers un autre projet ; les deux index suivent |
| `GET /api/agent` (alias `/api/tools`, `/api/skills`) | mode agent, compactage, mode mémoire, liste des pages |
| `POST /api/agent/toggle` (et alias) | mode agent (`state/agent`) |
| `POST /api/agent/compact` | compactage automatique (`COMPACT`) |
| `GET|POST /api/memory` | mode mémoire du projet actif, repli sur `MEM_MODE` |
| `GET|POST /api/tracker` | panneau des trackers : liste, détail, ajout, modification, suppression, renommage, déplacement |
### Disque et base
Les pages restent des fichiers `memory/<projet>/<nom>.md`, et l'index
`MEMORY.md` reste en clair. Le Go continue de les lire et de les écrire pour
sa propre boucle : outils `mem_*`, index injecté au modèle. Les écritures
restent sûres, comme dans le Go :
- un `.bak` de la version précédente est gardé ;
- le fichier est écrit dans un temporaire, synchronisé, puis renommé ;
- le contenu est relu et comparé octet par octet.
Les trackers restent dans le bucket `tracker`, sous la clé
`<projet>/<slug>`. Le registre des projets (`state/projects`) est lu tel
quel. Le mode mémoire y est écrit avec l'échappement HTML de
`encoding/json`, pour garder les mêmes octets en base.
### Chiffrement
La clé de la mémoire ne quitte pas le Go :
- une page qui porte le magic `LOKIMEMv1` est ouverte par
`/internal/v1/crypt/open` ;
- toute écriture de page ou de tracker passe par `/internal/v1/crypt/seal`,
qui rend le clair tel quel quand le chiffrement est coupé.
Quand la mémoire est verrouillée :
- les pages restent listées avec le titre « 🔒 (chiffré — mémoire
verrouillée) » ;
- les trackers illisibles sont tus ;
- aucune écriture ne retombe en clair.
### Messages d'erreur JSON
Ces routes renvoient au client le message d'erreur de décodage du Go.
`gojson.rs` reproduit `json.NewDecoder(r.Body).Decode(&req)` :
- seule la première valeur compte ;
- `EOF` et `unexpected EOF` ;
- les erreurs du scanner (`invalid character …`) ;
- les erreurs de type, champs anonymes compris ;
- la casse des noms de champs, et l'allocation des pointeurs.
`gojson.rs` est mutualisé pour les lots suivants.
## Restent au Go
- Le coffre et le chiffrement (`/api/mem/encrypt|decrypt|unlock|lock`).
- La santé, les instantanés, la sauvegarde.
- Le hub des projets (`/api/projects`) : sa bascule recharge la discussion.
- La bascule temporaire de projet d'une tâche planifiée. Elle n'existe qu'en
RAM du Go : la façade sert toujours le projet actif rangé.
## Écarts assumés
- Un corps JSON syntaxiquement valide mais qui fait échouer serde (UTF-8
invalide, nombre hors limites) donne le message de serde.
- Une date d'un tracker qui tombe dans un trou d'heure d'été est résolue par
`mktime`.
## Parité
### Décodage JSON
`internal/loki/parity_gojson_test.go` tire du Go 81 corps décodés : champs
et message d'erreur. `tests/gojson.rs` les rejoue.
### Mémoire
`internal/loki/parity_mem_test.go` tire du Go :
- les noms de pages, les titres, les slugs et les modes mémoire ;
- 36 dates de trackers, lues dans un fuseau fixe (UTC+2) ;
- 102 appels de route, avec après chacun le dossier `memory/` complet
(`.bak` compris) et les clés touchées en base (mode agent, compactage,
mode mémoire, projets, trackers).
Ces appels couvrent :
- les pages : création, renommage, réenregistrement qui perd l'accroche,
noms refusés, fichiers cachés et dossiers ignorés ;
- le déplacement, avec ses six refus ;
- la bascule de projet ;
- le mode agent et le compactage ;
- le mode mémoire, y compris un projet actif inconnu ;
- les trackers : ajout, modification, suppression, renommage, déplacement,
valeurs illisibles et événements nuls.
`tests/memory.rs` rejoue le tout par la façade, avec le même masquage des
identifiants et des instants « maintenant ».
Une mutation de chaque harnais fait échouer son rejeu.
Un essai de bout en bout simule le chiffrement par le faux Go :
- les pages sont écrites scellées et relues en clair ;
- l'index reste en clair ;
- les trackers sont scellés ;
- une fois la mémoire verrouillée, rien n'est réécrit en clair.
@@ -0,0 +1,97 @@
# Lot 14 — `loki serve` composé en Rust
## Contexte
Depuis le lot 3, la supervision du moteur est en Rust : `loki-rs engine
start|stop|restart`. Mais elle lançait encore `loki serve` (Go), qui compose
la ligne de commande de llama-server avant de lui céder la place.
Cette ligne vit dans `backend_serve*.go`, environ 4 000 lignes. Chaque option
y a été réglée contre un défaut mesuré :
- couches GPU et placement automatique (`--fit`, `--fit-target`) ;
- threads et sonde de conteneur ;
- files de lancement CUDA ;
- second slot, parallélisme de tenseurs ;
- cache de prompts, points de reprise des hybrides ;
- décodage spéculatif (MTP, brouillon, n-grammes) ;
- garde-fous de fidélité, garde-fou de chargement (LOAD_GUARD) ;
- état des slots gardé à la bascule.
Lot 14 : `loki-rs engine serve` fait ce travail quand `LOKI_SERVE=rust`
(expérimental). Le superviseur le lance à la place de `loki serve`. Sans la
variable, rien ne change.
## Découpage
- `serve/` : port fonction par fonction. `mod.rs` contient
`build_serve_args` et les outils de drapeaux. Les modules suivent les
fichiers Go :
- `threads`, `queues`, `expert`, `fidelity` ;
- `side`, `split`, `cache`, `ckpt` ;
- `spec`, `moe`, `persist`.
Tout ce qui décide est pur.
- `serve/run.rs` porte `cmdServe`. Il lit la configuration, la clé d'API, les
dossiers de modèles et le jeton de tentative de SPEC=auto par l'API interne
du Go. Il sonde ensuite :
- l'aide du moteur, `--version`, `--list-devices` (bornés dans le temps,
avec les bibliothèques du moteur) ;
- le GGUF ;
- la RAM, la VRAM (nvidia-smi), le cgroup et le cpuset ;
- les variables `LLAMA_ARG_*` et `GGML_*`.
Il vérifie que le port est libre et écrit le marqueur de SLOT_PERSIST. Il
garde la commande en base pour l'interface (`engine_cmdline`), puis fait
`exec` du moteur.
- La façade passe au Go l'adresse de son API interne (`LOKI_GO_ADDR`), à côté
du jeton. Le superviseur, lancé par le Go, en hérite, et `engine serve` aussi.
## Restent au Go
`loki serve` (Go) est lancé à notre place dans ces cas, à l'identique :
- un preset Strata (son propre serveur, lot à venir) ;
- le moteur d'essai de l'optimiseur (`LOKI_TUNE_TRIAL`) ;
- l'absence d'API interne (lancement à la main hors de la façade).
Restent aussi au Go les aperçus de l'éditeur de preset (`/api/preset/cacheram`,
`/api/preset/moe`, « Dupliquer en placement auto »). Ils viendront avec le lot
des presets.
## Écarts assumés
- Le message d'un brouillon (`MODEL_DRAFT`) introuvable cite l'erreur système
au format Rust.
- Pas de cache de l'aide ni du build par binaire : `engine serve` ne compose
qu'une ligne par lancement, le cache du Go ne servait qu'au process web.
- Le jeton de tentative de SPEC=auto est lu puis réécrit par l'API interne,
sans transaction : le Go seul l'efface, une fois le moteur prêt.
## Parité
`internal/loki/parity_serve_test.go` tire du Go 1 872 lancements, chacun
avec sa ligne de commande, son environnement et ses notes. Les lancements
croisent :
- 12 machines (VRAM, RAM, GGUF dense, MoE, hybride, par couches, architecture
exclue, brouillons, mémoire unifiée, conteneur à l'étroit) ;
- 112 presets ;
- 3 aides de moteur (récent, ancien, inconnu) ;
- 13 jeux de variables `LLAMA_ARG_*`, et des variables `GGML_*`.
Le journal comprend aussi les sondes pures :
- listes de CPU, cpuset et quotas, mémoire du cgroup ;
- sortie de nvidia-smi, `--list-devices`, build du moteur ;
- forme copiable de la commande ;
- verdicts de SPEC=auto ;
- drapeaux de chargement, taille d'état par jeton.
`tests/serve.rs` rejoue le tout contre `serve::build_serve_args`, argument
par argument. Une mutation du calcul du cache, des files CUDA ou des bornes
des n-grammes fait échouer le rejeu.
Deux essais de bout en bout lancent le vrai `loki-rs engine serve` contre un
faux Go et un faux llama-server. Ils vérifient :
- la ligne exécutée et le chemin des bibliothèques ;
- la commande gardée en base ;
- le refus d'un port pris, d'un modèle absent ;
- la sortie propre d'un preset externe ;
- la délégation au Go (sans API interne, essai de l'optimiseur, Strata).
@@ -0,0 +1,86 @@
# Lot 15 — presets tenus par la façade
## Contexte
Les presets vivaient dans le Go :
- `backend_presets.go` pour l'écriture ;
- les handlers de `web_api.go` et `web_external.go` ;
- les aperçus de l'éditeur (`cacheRAMPreview`, `sideSlotPreview`,
`moePreview`, `autoPlacementPreset`).
Lot 15 : la façade tient ces routes quand `LOKI_PRESETS=rust`
(`presets_api.rs`, expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `GET /api/presets` | liste, avec pastilles (contexte, vision, quantification, raisonnement, Strata, externe) et mesure du bench si elle correspond à la configuration actuelle |
| `POST /api/presets/order` | ordre choisi par glisser-déposer |
| `GET /api/preset?id=` | contenu et prompt système ; sans id, la graine d'un preset neuf (BIN personnalisé, HOST, PORT) |
| `POST /api/preset/save` | crée (nom de fichier unique) ou met à jour ; le preset EN SERVICE est appliqué tout de suite |
| `POST /api/preset/delete` | refusé pour le preset actif ; emporte prompt système et mesure, et le modèle sur demande |
| `POST /api/preset/cacheram`, `/api/preset/moe` | aperçus de l'éditeur, calculés comme au lancement (lot 14) |
| `POST /api/preset/autoplace` | copie en placement automatique d'un preset MoE placé à la main |
| `GET /api/preset/external?id=`, `POST /api/preset/external/save`, `/api/preset/external/test` | presets externes ; la clé n'est jamais rendue, le test fait un vrai appel de complétion |
| `POST /api/switch` | bascule de preset |
Les presets restent des fichiers `presets/*.env`, et leur prompt système une
clé de la base. Les aperçus réutilisent le port de `loki serve` (lot 14),
sondes comprises.
## Restent au Go
Tout ce qui touche au moteur reste au Go, jusqu'au lot qui le portera. Le Go
expose deux routes internes pour cela (`facade_presets.go`).
`POST /internal/v1/preset/apply {path, mode}` applique un preset à la
configuration vivante :
- `switch` : l'état du slot est relevé avant (SLOT_PERSIST), puis le moteur
est relancé, ou arrêté pour un preset externe ;
- `save` : application, puis relance ;
- `apply` : la configuration seule (preset externe en service modifié).
`GET /internal/v1/tune/guard` dit si une optimisation tient le moteur. La
réponse est alors un 409 `{ok: false, tuning: true}`, comme le Go.
## Écarts assumés
- La suppression d'un preset retire sa mesure sans réécrire les autres avec
leurs champs à zéro, comme le faisait la struct du Go. Le Go les relit
pareil.
- Le message d'un échec de réseau du test externe est celui de reqwest.
## Parité
`internal/loki/parity_presets_test.go` tire du Go deux séries.
Les fonctions pures :
- nom d'affichage, guillemets, format `.env`, noms refusés ;
- quantification, raisonnement, modèle ;
- URL de complétions, contenu d'un preset externe, message d'erreur d'API ;
- arguments rejoints, clé réécrite dans un preset ;
- huit copies en placement auto ;
- empreinte de mesure, moteur imposé par un preset.
Puis 70 appels de route, avec après chacun les dossiers `presets/` et
`models/`, et les clés touchées en base. Ils couvrent :
- créations, doublons, noms refusés, erreurs JSON ;
- ordre, pastilles et mesures (empreinte, repli sur le modèle, autre
configuration) ;
- preset actif et suppressions (modèle compris, partagé, absent) ;
- presets externes et leur test contre un faux service (succès, 401 au
format OpenAI, 404 brut, 500 vide) ;
- placement auto et aperçus.
`tests/presets_api.rs` rejoue le tout par la façade, et une mutation fait
échouer le rejeu.
La bascule et l'enregistrement du preset en service sont vérifiés de bout en
bout contre le faux Go :
- appels d'application, avec leur mode ;
- refus quand une optimisation tourne ;
- erreur d'application rendue ;
- prompt système.
Côté Go, `TestFacadePresetApply` vérifie `presetApply` : configuration
appliquée, appareils préservés, moteur relancé, chemin hors du dossier
refusé.
@@ -0,0 +1,90 @@
# Lot 16 — pilotage du moteur tenu par la façade
## Contexte
Le Go pilotait le moteur depuis l'interface :
- `svcHandler` (`web_api.go`) pour `/api/start`, `/api/stop` et
`/api/restart` ;
- `handleVramUnload` et `handleVramReload` (`web_vram.go`) pour rendre la
carte graphique à la machine, puis la reprendre ;
- `serviceAction` (`sys_service.go`) : le verrou de l'optimiseur, puis, en
conteneur, le superviseur Rust `loki-rs engine <action>` (lot 3,
`sys_service_rust_linux.go`) ;
- `preflightEngine` : ce sans quoi le moteur ne peut pas démarrer, dit avant
de le lancer.
Lot 16 : la façade tient ces routes quand `LOKI_ENGINE=rust`
(`enginectl.rs`, expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `/api/start`, `/api/stop`, `/api/restart` | pilotage du moteur ; réponse `{ok, out}` comme le Go |
| `POST /api/vram/unload` | arrête le moteur et la dictée, rapporte la VRAM rendue ; refusé pendant une réponse en cours sauf `{force: true}` |
| `POST /api/vram/reload` | relance le moteur après le contrôle d'avant démarrage ; `{already: true}` s'il tourne |
La façade lance elle-même `loki-rs engine <action>`, avec l'environnement que
le Go lui passait (`LOKI_GO_BIN`, `LOKI_HOME`, `LOKI_SERVICE`) et de quoi
joindre l'API interne du Go pour `loki-rs engine serve` (`LOKI_GO_ADDR`,
`LOKI_INTERNAL_TOKEN`). Sa dernière ligne d'erreur devient l'erreur rendue,
sans le préfixe du binaire. Un preset externe ne lance rien ; un redémarrage
arrête alors le moteur local.
Le bilan de VRAM suit `gpuSettle` :
- une lecture ratée est sautée, jamais comptée « 0 Mo » ;
- un palier ne conclut qu'une fois la baisse commencée ;
- les jauges du moniteur sont invalidées après coup.
## Quand la façade ne tient pas ce lot
La façade ne prend ce lot qu'en conteneur, avec son superviseur. Sous
systemd, ou avec `LOKI_ENGINE_SUPERVISOR=go`, `loki-rs` ne passe pas
`LOKI_ENGINE` au Go, qui garde les routes (décidé au démarrage, raison dans
le journal).
## Restent au Go
Le Go expose trois routes internes (`facade_engine.go`) :
- `POST /internal/v1/service/prestart` : avant tout démarrage, refus pendant
une optimisation, puis ménage d'un verrou périmé (essai orphelin arrêté,
application interrompue défaite). Le verrou de l'optimiseur suit le
processus qui le tient, il reste au Go jusqu'au lot de l'optimiseur ;
- `GET /internal/v1/chat/generating` : la réponse en cours, quand le Go tient
la conversation (sinon la façade lit la sienne) ;
- `POST /internal/v1/dictate/stop` : l'arrêt de la dictée, quand le Go la
tient (sinon la façade arrête la sienne).
`GET /internal/v1/tune/guard` (lot 15) sert le refus 409 du rechargement.
Les autres appelants de `serviceAction` côté Go (bascule de preset,
optimiseur, mises à jour du moteur) passent toujours par le Go. Les deux voies
lancent le même superviseur, sur les mêmes fichiers PID et journal.
## Écarts assumés
- `engineNeedsStop` regarde aussi les états transitoires de systemd. En
conteneur, seul cas où la façade tient ce lot, il se réduit au PID vivant.
- Un superviseur tué par un signal est décrit comme par le Go (« signal:
killed ») pour les signaux courants, par son numéro sinon.
## Parité
`internal/loki/parity_engine_test.go` tire du Go :
- dix bilans de VRAM (palier avant la baisse, lectures ratées, VRAM en
hausse…) ;
- la dernière ligne d'erreur du superviseur et le retrait des couleurs ;
- vingt-six installations passées au contrôle d'avant démarrage (preset
externe, Strata, BIN absent ou relatif, MODEL absent, hors des dossiers,
remontant, en tranches incomplètes…) ;
- dix réponses de route qui ne lancent rien (GET refusé avec `Allow: POST`,
optimisation ou réponse en cours, moteur déjà arrêté, contrôle en échec).
`tests/enginectl.rs` rejoue le tout par la façade. Il vérifie aussi de bout en
bout, avec un faux superviseur et un faux nvidia-smi :
- démarrage, arrêt, redémarrage, refus pendant une optimisation ;
- échec du démarrage, preset externe ;
- déchargement chiffré, arrêt en échec, VRAM en hausse ;
- rechargement.
Une mutation de chaque règle fait échouer les tests.
Côté Go, `TestFacadeMoteur` vérifie le relais et les trois routes internes.
@@ -0,0 +1,92 @@
# Lot 17 — interrupteurs système tenus par la façade
## Contexte
Quatre réglages de l'interface vivaient dans le Go :
- l'accès internet de l'IA : `handleInternet`, `chat_internet.go`,
`web_fetch_go.go` (moteur web, serveur Crawl4AI, clé, joignabilité) ;
- le pilotage du navigateur : `handleComputer`, `chromePath`,
`visionEnabled` ;
- la clé de complétion du moteur : `handleAPIKey`, `backend_config.go`,
`oaiBaseURL` ;
- l'écoute réseau du moteur : `handleNetwork`, `sys_network.go`.
Lot 17 : la façade tient ces routes quand `LOKI_SYSTEM=rust` (`system.rs`,
expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `GET\|POST /api/internet` | interrupteur, moteur web (`go` ou `crawl4ai`), URL et clé du serveur Crawl4AI (la clé n'est jamais rendue, seulement ses quatre derniers caractères), joignabilité |
| `GET\|POST /api/computer` | interrupteur du navigateur, binaire trouvé, vision du modèle |
| `GET\|POST /api/apikey` | clé de complétion : génération, saisie, effacement ; adresse `/v1` à coller dans un client tiers |
| `GET\|POST /api/network` | moteur ouvert au réseau ou à cette machine seulement (`HOST`), adresse de l'endpoint |
Les réglages restent des clés de la base, écrites comme le Go : une chaîne
vide efface la clé, et l'URL comme les clés ne laissent rien traîner dans la
configuration.
La joignabilité de Crawl4AI suit `crawlReachable` :
- `/health`, puis `/`, en moins de deux secondes en tout ;
- clé envoyée en `Authorization: Bearer` ;
- moins de 500 = joignable ;
- cache de 30 s par URL, vidé quand l'URL, la clé ou le moteur changent.
## Restent au Go
Le Go expose trois routes internes (`facade_system.go`) :
- `POST /internal/v1/web/reach/reset` : le cache de joignabilité des outils
web, encore exécutés par le Go, est vidé à chaque changement ;
- `POST /internal/v1/cdp/shutdown` : le Chromium des outils `browser_*` se
ferme quand l'interrupteur se coupe ;
- `POST /internal/v1/service/action {action, if_active}` : une clé neuve ne
s'applique qu'au redémarrage du moteur. La façade le relance elle-même si
elle le pilote (lot 16), sinon le Go, seulement s'il tourne.
`GET /internal/v1/tune/guard` (lot 15) refuse un changement de clé pendant une
optimisation.
Le relais du tunnel vers la façade garde désormais l'hôte et
`X-Forwarded-Proto` du client : l'adresse de la clé de complétion est celle
que le client a tapée, pas celle de la façade.
## Correction en passant
`maskAPIKey` paniquait sur une clé d'un seul octet (`k[len(k)-2:]`).
`/api/apikey` tombait alors à chaque lecture tant que la clé restait en place.
Une clé d'un ou deux octets s'affiche maintenant « … », côté Go comme côté
Rust.
## Écarts assumés
- Hors Windows, aucun pare-feu n'est piloté : la façade ne tourne qu'en
conteneur Linux, où le Go répondait déjà « inconnu ».
- La clé masquée est coupée sur les octets, comme le Go. Un caractère coupé
devient U+FFFD, octet par octet, comme son encodeur JSON.
## Parité
`internal/loki/parity_system_test.go` tire du Go 61 appels de route, avec
après chacun les clés touchées en base et les en-têtes reçus par trois faux
serveurs Crawl4AI (200, 500, 404). Ils couvrent :
- moteurs inconnus, URL avec et sans slashs, injoignable, sans schéma ;
- clés longues, courtes, d'un caractère, résiduelle en configuration ;
- erreurs de type qui laissent passer les autres champs, `null` ;
- vision par projecteur, preset externe ou Strata ;
- clé générée (masquée dans le journal), saisie, de treize octets, repli sur
`config.env`, adresse publique, `X-Forwarded-Proto`, refus pendant une
optimisation ;
- écoute ouverte, fermée, `localhost`, `::1`, port illisible.
Il tire aussi huit dispositions du dossier de Playwright pour le Chromium
retenu.
`tests/system.rs` rejoue le tout par une seule façade, cache de
joignabilité compris. Il vérifie de bout en bout ce qui reste au Go : cache
vidé, navigateur fermé, moteur relancé par le Go, ou par le superviseur
quand la façade pilote le moteur. Une mutation de chaque règle fait échouer
les tests.
Côté Go :
- `TestFacadeSysteme` vérifie le relais et les trois routes internes ;
- `TestFacadeRelaisGardeHote` vérifie l'hôte gardé ;
- `TestMaskAPIKeyCourte` vérifie le masque des clés courtes.
@@ -0,0 +1,78 @@
# Lot 18 — hub des projets tenu par la façade
## Contexte
Le hub des projets vivait dans le Go :
- `handleProjects` et `projectDTOs` (`web_projects.go`) ;
- les mutations de `projects.go` : `createProject`, `renameProject`,
`setProjectDesc`, `deleteProject` ;
- `projectSwitch` (`chat_sessions.go`).
Lot 18 : la façade tient `/api/projects` quand `LOKI_PROJECTS=rust`
(`projects_api.rs`, expérimental). Sans la variable, rien ne change.
| Action | Rôle |
|---|---|
| `GET` | les projets, le plus ancien d'abord, avec leurs discussions, pages (`.md` visibles, index exclu) et trackers comptés ; le projet actif |
| `create` | slug translittéré (`Réseau NAS` → `reseau-nas`), unique (`-2`, `-3`…), `projet` à défaut, 40 octets au plus ; nom de 60 runes au plus ; dossier `memory/<slug>` et son index amorcé |
| `rename` | le libellé seul : slug, chemins et discussions ne bougent pas |
| `desc` | 2 000 runes au plus, vide = effacée |
| `switch` | la conversation passe sur le projet (la plus récente de ses discussions, ou une neuve) |
| `delete` | discussions, trackers et mémoire du projet ; le projet actif est réassigné ; le dernier projet est indestructible |
Le registre reste dans la base (`state/projects`, écrit comme `json.Marshal`
avec le HTML échappé), la mémoire de chaque projet dans `memory/<slug>`.
## Restent au Go
Le projet actif passe toujours par le Go (`/internal/v1/project/activate`),
qui en garde une copie en RAM pour la mémoire, les outils et les tâches.
Les discussions sont à qui tient la conversation.
Quand la façade la tient (`LOKI_CHAT=rust`), elle bascule et supprime
elle-même. Avant de supprimer le projet actif, elle enregistre sa discussion
et en ouvre une du premier projet restant.
Sinon le Go garde deux routes internes (`facade_projects.go`) :
- `POST /internal/v1/project/switch` : `projectSwitch` (discussion du projet
quitté enregistrée, plus récente du projet rejoint ouverte, ou une neuve) ;
- `POST /internal/v1/project/drop-convs` : les discussions du projet
supprimées (`convDelete`, fichiers et résultats compris).
Une génération en cours refuse la bascule et la suppression (409). La façade
lit la sienne, ou celle du Go (`/internal/v1/chat/generating`, lot 16).
## Écarts assumés
- Pendant une tâche planifiée, le Go voit comme actif le projet de la tâche ;
la façade lit le projet actif enregistré.
- Pendant un tour de la façade, une bascule vers un projet inconnu répond 409,
comme quand le Go tient la conversation. Le Go, relayant à la façade,
répondait 400 (« projet introuvable ») dans ce cas.
## Parité
`internal/loki/parity_projects_test.go` part d'une base amorcée :
- pages visibles, cachées, hors `.md`, dans un sous-dossier ;
- discussions de Générale, d'avant les projets, d'un projet à venir et d'un
projet disparu ;
- trackers, dont `alphabet/x` qui ne doit pas tomber avec `alpha`.
Il tire du Go 40 appels, avec après chacun le registre, le projet actif,
l'arbre `memory/`, les trackers et l'index des discussions. Ils couvrent :
- créations (doublons, translittération, sans lettres, longues, vides) ;
- renommages et descriptions (longues, coupées sur une espace, effacées) ;
- bascules (sur l'actif, inconnue, vers un projet sans discussion, pendant
une génération) ;
- suppressions (de l'actif, inconnue, pendant une génération, jusqu'au
dernier projet) ;
- actions inconnues, corps vide ou illisible, erreur de type, méthode
refusée.
`tests/projects_api.rs` rejoue le tout par la façade, contre un faux Go qui
bascule et supprime les discussions comme le vrai. La bascule et la
suppression quand la façade tient la conversation sont vérifiées de bout en
bout. Une mutation de chaque règle fait échouer les tests.
Côté Go, `TestFacadeProjets` vérifie le relais et les deux routes internes.
@@ -0,0 +1,65 @@
# Lot 19 — export, mode Code et critères de la discussion
## Contexte
Avec `LOKI_CHAT=rust`, la façade tient la conversation (lot 6), mais trois
routes de la discussion ouverte restaient au Go :
- `/api/chat/export` (`chat_export.go`). Il relisait la copie enregistrée,
qui peut avoir un temps de retard sur la conversation vivante ;
- `/api/chat/mode` (`code_mode.go`) : mode Chat ou Code de la discussion, et
ses critères ;
- `/api/chat/criteria` : critères édités à la main.
Lot 19 : ces trois routes rejoignent celles de la conversation. La façade
les sert quand elle la tient (`chatx.rs`, `facadeChatRoutes` côté Go), sans
nouvelle variable. Sans `LOKI_CHAT=rust`, rien ne change.
| Route | Rôle |
|---|---|
| `GET /api/chat/export` | Markdown (fait pour être lu) ou JSON (fait pour être retraité), tiré de la conversation vivante ; options `format`, `reasoning`, `tools`, `results`, `turns` |
| `GET\|POST /api/chat/mode` | mode de la discussion ouverte (`chat/mode:<id>`, absent = chat) et ses critères ; un changement part sur le fil (`{mode}`) |
| `POST /api/chat/criteria` | critères édités : vides écartés, vingt au plus, ids posés, statuts ramenés à `pending`/`passed`/`failed` ; scellés comme le contenu des discussions (`chat/crit:<id>`), sur le fil (`{criteria, ts}`) |
L'export suit `chat_export.go` :
- Markdown : un bloc par tour, le raisonnement replié dans un `<details>`,
les outils en liste (seul l'état final d'un outil compte, comme au
rejeu), la compaction signalée, un export partiel annoncé en tête ;
- JSON : indenté et HTML échappé comme `json.MarshalIndent`, vue modèle sans
mise à jour de contexte (`PROJ_SNAPSHOT`) ni image en base64 (élidée avec
sa taille), journal filtré par les mêmes cases ;
- les derniers échanges comptent à partir des messages de l'utilisateur, pas
des rappels de Loki.
## Restent au Go
Le reste du mode Code reste au Go et lit les mêmes clés : l'indice « ça
ressemble à du code », les travaux de fond et l'outil `criteria` de l'agent.
## Écarts assumés
- L'export part de la conversation vivante, pas de sa copie enregistrée :
le dernier tour y est, même tout juste fini.
- Les images d'un tour en cours restent dans l'export JSON, élidées. Une
conversation relue par le Go les avait déjà perdues.
## Parité
`internal/loki/parity_chatx_test.go` tire du Go l'export d'une conversation
riche sous 22 jeux d'options lus dans la requête. La conversation contient :
- pièces jointes, raisonnement en deltas ;
- outils finis, en cours, avec et sans sortie, labels piégés pour le
Markdown ;
- statistiques, compaction ;
- images en base64 et en URL, rappel de Loki ;
- mise à jour de contexte marquée, et une tapée par l'utilisateur.
Plus les exports d'une conversation vide.
Il tire aussi 23 appels aux routes du mode et des critères, avec la base et
les événements du fil après chacun : erreurs JSON, y compris celles d'un
critère mal typé (`Criterion.criteria.id`), et au-delà de vingt critères.
`tests/chatx.rs` rejoue l'export octet pour octet et les routes par la
façade. Il vérifie aussi les en-têtes du téléchargement et le maintien des
routes au Go sans la conversation. Une mutation de chaque règle fait échouer
les tests.
@@ -0,0 +1,93 @@
# Lot 20 — serveurs MCP tenus par la façade
## Contexte
Le Go tenait les serveurs MCP (Model Context Protocol) :
- `mcp_config.go` : la configuration, dans `state/mcp` ;
- `mcp_catalog.go` : le catalogue embarqué ;
- `mcp_client.go` : le pool de sessions, avec le SDK MCP Go ;
- les routes `/api/mcp*` de `web_api.go`.
Le pool sert deux usages : l'état affiché dans l'interface, et les outils
`mcp__<serveur>__<outil>` annoncés au modèle et exécutés pour l'agent.
Lot 20 : la façade tient tout cela quand `LOKI_MCP=rust` (`mcp/`,
expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `GET /api/mcp` | état de chaque serveur, connexion tentée pour les activés (en parallèle) |
| `GET /api/mcp/catalog` | catalogue embarqué (le même fichier que le Go), `{workspace}` résolu, entrées déjà configurées marquées, lanceur (npx, uvx) présent ou non |
| `POST /api/mcp/save` | ajoute ou remplace (actif par défaut) ; nom contrôlé, un seul transport |
| `POST /api/mcp/delete`, `/api/mcp/toggle`, `/api/mcp/tool` | retire, active ou éteint, masque ou démasque un outil |
| `POST /api/mcp/test` | reconnexion forcée |
## Client MCP en Rust
`mcp/client.rs` parle JSON-RPC 2.0 sur deux transports, comme le SDK Go :
- stdio : un processus local, un message par ligne ;
- Streamable HTTP : un POST par message, réponse en JSON ou en flux SSE,
session suivie par `Mcp-Session-Id`, en-têtes configurés ajoutés.
La session s'ouvre par `initialize` puis `notifications/initialized`. Elle
sert `tools/list` (première page, comme le Go) et `tools/call`. Un `ping` du
serveur reçoit sa réponse.
Le pool (`mcp/mod.rs`) suit `mcp_client.go` :
- une session gardée d'un tour à l'autre ;
- une entrée publiée avant la connexion, pour que deux appelants partagent
la même tentative ;
- une session invalidée pendant sa connexion n'est jamais republiée ;
- délai de 3 min pour npx et uvx (premier téléchargement), 20 s sinon ;
- un appel borné à 120 s, une reconnexion si la session est morte ;
- la sortie plafonnée à la fenêtre de contexte (12 000 au moins), une erreur
d'outil signalée, une sortie vide rendue « [ok] (aucune sortie) ».
## Le Go
Un seul pool, celui de la façade. Le Go, qui exécute encore les outils de
l'agent, lui demande les outils à annoncer, leurs appels et le
pré-chauffage (`/internal/facade/v1/mcp/{tools,call,prewarm}`,
`facade_mcp.go`), quelle que soit la conversation qui tourne. Son propre
pool reste vide.
L'image copie `internal/loki/mcp_catalog.json` dans l'étape de compilation
Rust : un seul catalogue pour les deux binaires.
## Écarts assumés
- Les erreurs de transport (connexion refusée, flux coupé) ont les messages
du client Rust. Une erreur JSON-RPC et un binaire introuvable ont ceux du
Go.
- Un contenu « autre » (lien de ressource, ressource intégrée) est rendu en
JSON dans l'ordre des champs du SDK. Les annotations éventuelles sont dans
l'ordre alphabétique.
## Parité
`internal/loki/parity_mcp_test.go` tire du Go :
- les fonctions pures : noms exposés, schémas, libellés d'appel, serveur
d'origine d'un outil, ligne du préambule, délais et leurs messages,
aplatissement de neuf résultats ;
- la lecture de la configuration et seize mutations avec leurs erreurs et la
base après chacune ;
- ce qu'un vrai serveur du SDK MCP Go met sur le fil en Streamable HTTP :
onze outils, et le résultat de chacun ;
- ce que le client Go en tire : état de quatre serveurs (joignable,
binaire introuvable, désactivé, mal configuré), outils annoncés (un
masqué), appels.
`tests/mcp.rs` rejoue les fonctions et la configuration. Un faux serveur
resert ce que le serveur du SDK a répondu (appels en SSE) : l'état, les
outils et les appels tirés par la façade doivent être ceux du Go, sur une
seule session. Sont aussi vérifiés de bout en bout :
- le transport stdio : environnement, ping du serveur, serveur mort en plein
appel puis reconnexion ;
- les routes de configuration, en-têtes compris ;
- le catalogue ;
- une session invalidée pendant sa connexion, sans processus laissé
derrière.
Une mutation de chaque règle fait échouer les tests. Côté Go,
`TestFacadeMCP` vérifie le relais, les outils, les appels, le
pré-chauffage et une façade injoignable.
@@ -0,0 +1,26 @@
# Lot 21 — mise à jour de Loki tenue par la façade
## Contexte
`/api/update` (version disponible) et `/api/update/apply` (téléchargement et
remplacement du binaire) vivaient dans `sys_update.go`. En conteneur
(`LOKI_CONTAINER=1`), le Go n'y fait rien. L'image se met à jour par Docker,
et les deux routes le disent (`containerUpdateNote`) :
- `/api/update` : `{current, available: false, note}` ;
- `/api/update/apply` : 409 `{ok: false, error: note}`.
## Le lot
La façade ne tourne qu'en conteneur. Elle tient ces deux routes quand
`LOKI_UPDATE=rust` (`sysupdate.rs`, expérimental) et `LOKI_CONTAINER=1`.
Sinon `loki-rs` ne passe pas la variable au Go, qui garde le téléchargement
et le remplacement du binaire hors conteneur.
La version annoncée est celle du Go (`/internal/v1/auth`). Les binaires de
la façade suivent toujours `loki-rs self-update`.
## Parité
`internal/loki/parity_update_test.go` tire du Go les quatre réponses en
conteneur (GET et POST sur chaque route). `tests/sysupdate.rs` les rejoue par
la façade, version du Go comprise.
@@ -0,0 +1,111 @@
# Lot 22 — backends llama.cpp et job des moteurs tenus par la façade
## Contexte
Le panneau Moteur s'appuie sur une famille de routes du Go :
- `web_llamacpp.go` : statut, vérification git, bascule fast/opt, job ;
- `backend_prebuilt.go` : binaires précompilés officiels ;
- `web_api.go` : backends custom et leur désinstallation ;
- `web_devices.go` : GPU vus par un moteur.
Elles partagent **un** job (`lcJob`) avec la mise à jour du moteur de l'image
(`web_engine.go`) et l'installation de Strata (`backend_strata.go`). L'entête
du job est en base (`state/build_job`), ses lignes dans
`$LOKI_HOME/loki-build.log`. Au démarrage, un job noté « en cours » est déclaré
interrompu (`lcRestore`).
## Le lot
Avec `LOKI_BACKENDS=rust` (expérimental), la façade (`rust/loki-rs/src/backends`)
sert les routes suivantes :
| Route | Rôle |
| --- | --- |
| `/api/llamacpp` | statut : dépôt git, binaire, moteur de l'image, précompilé, moteur téléchargé, plan de build, conseil, job |
| `/api/llamacpp/check` | `git fetch`, puis le retard sur origin |
| `/api/llamacpp/prebuilt/check` | dernière release officielle comparée à la version installée |
| `/api/llamacpp/prebuilt` | job : téléchargement, extraction (liens compris), VERSION, ménage, BIN, service arrêté puis relancé |
| `/api/llamacpp/use` | bascule fast/opt, refusée pendant une optimisation |
| `/api/llamacpp/job` | progression et lignes à partir d'un décalage |
| `/api/llamacpp/uninstall-custom` | désinstallation d'un backend custom |
| `/api/backends`, `/api/backends/custom` | les backends présents |
| `/api/backends/devices` | GPU vus par un moteur, avec un cache mémoire et un cache disque |
**Le job vit dans la façade** (`backends/job.rs`) : même entête en base, même
journal, même reprise au démarrage. Les jobs que le Go fait encore tourner lui
rapportent chaque étape par l'API interne `/internal/facade/v1/build-job/*`
(`start`, `line`, `phase`, `fail`, `done`, `commits`, `end`, `snapshot`).
Ces jobs sont :
- la compilation depuis les sources (`install`, `update`, `install-custom`,
relayées au Go qui garde son refus 409 quand l'image fournit le moteur) ;
- la mise à jour du moteur de l'image ;
- Strata.
Côté Go (`facade_backends.go`), quand la façade tient le lot :
- `startLcJob`, `lcAppend`, `lcPhase`, `lcFail`, `lcDone`, `lcSetCommit` et
`lcJobSnapshot` passent par la façade ;
- le garde de l'optimiseur reste au Go, avant le démarrage ;
- le Go ne restaure plus de job au démarrage ;
- les routes reçues par le tunnel sont relayées à la façade.
Pour arrêter et relancer le service, la façade pilote le moteur si elle le
tient (lot 16), sinon elle le demande au Go (`/internal/v1/service/action`,
avec `if_active`). Les sondes de la machine passent par un PATH et une racine
injectables (`backends/probe.rs`) :
- `nvidia-smi`, `nvcc`, `hipcc`, `/opt/rocm` ;
- `glslc`, `vulkaninfo`, `ldconfig` et les chemins de `libvulkan`.
## Correction du Go
`prebuiltVersionFormat` lisait le format comme 3ᵉ champ du marqueur VERSION.
Hors CUDA Windows, la version CUDA est vide : le marqueur
« `b300 fmt2` » n'a alors que deux champs. Le format n'était donc jamais
reconnu, et chaque vérification retéléchargeait la release sous Linux et
macOS au lieu de dire « déjà à jour ». Le format est désormais le dernier
champ, et la version CUDA ignore ce champ. Le port Rust suit.
## Parité
`internal/loki/parity_backends_test.go` (Linux) tire du Go un journal rejoué
par `tests/backends.rs`. Le journal est sauté si `/usr/local/cuda*` ou
`/opt/rocm` existent sur la machine.
Le journal contient :
- **le statut** dans tous ses états :
- dépôt git reproductible (dates et identité fixées), retard connu ou vérifié, tête détachée ;
- origine perdue, BIN dans un fork ;
- précompilé versionné, moteur téléchargé, moteur de l'image ;
- **les backends custom** et chaque refus de désinstallation ;
- **la bascule fast/opt** ;
- **la dernière release** :
- refus HTTP, tag vide, JSON illisible ;
- aucun asset adapté ;
- **le job précompilé** :
- installation, « déjà à jour », ancien format réextrait ;
- optimisation en cours, GitHub qui refuse ;
- archive absente, archive piégée (`../`), archive sans serveur ;
- **les GPU** :
- liaison PCIe, mémoire complétée par nvidia-smi ;
- cache, mémoire nulle non figée, repli sur le disque ;
- moteur planté, tué ou non exécutable, pilote ancien, cartes de même nom ;
- **après chaque appel** : BIN, entête en base, journal, `devices.json` et l'arborescence de `backends/` et `engine/` ;
- **machine par machine** (dix fausses machines) : le plan de build, le conseil et la variante de release ;
- **le job pas à pas** :
- phases déduites des lignes de build, troncature à 4000 lignes, vues à partir d'un décalage ;
- écritures en base limitées à une toutes les 2 s ;
- refus d'un second job ;
- reprise au démarrage dans tous les cas : absent, illisible, fini, interrompu, journal vide ou trop long.
Autres tests :
- le Go (`TestFacadeBackends`) vérifie que ses jobs rapportent chaque étape à
la façade, dans l'ordre, et que la compilation n'est pas relayée ;
- la façade (`api_interne_du_job`, `service_arrete_puis_relance`) vérifie
l'API interne et le service arrêté puis relancé autour de l'installation.
## Écarts assumés
- Les erreurs réseau (GitHub injoignable) gardent le texte de `reqwest`.
- Quand un asset de la release a un champ de mauvais type, le message n'est
pas celui du Go.
- `git fetch` lancé par `/api/llamacpp/check` pendant un job du Go n'écrit
plus ses lignes dans ce job.
@@ -0,0 +1,85 @@
# Lot 23 — sonde de gabarit portée en Rust
## Contexte
La sonde de gabarit (`chat_tplprobe.go`) dit ce que le gabarit de chat du
modèle chargé fait de ce qu'on lui envoie :
- `preserves_history` : le raisonnement d'un tour passé est-il encore rendu ?
- `prefix_stable` : après un tour d'outil, le rendu précédent reste-t-il un
préfixe exact du suivant ?
- `renders_reasoning` : `reasoning_content` est-il rendu quelque part ?
Le Go la lance après chaque complétion du fil principal, avec les outils et
les `chat_template_kwargs` de la requête. Trois choses lisent son verdict :
- la politique d'écho du raisonnement (`REASONING_ECHO`) ;
- le rappel de budget glissé dans le résultat d'outil (`NUDGE_IN_TOOL`) ;
- le résumé des performances (`/api/perf/summary`).
Le relevé du rendu (`tplRenderPrint`) et sa comparaison (`tplRenderDiff`)
servent le panneau Moteur, autour d'une mise à jour.
Quand la façade tenait la conversation (`LOKI_CHAT=rust`), plus aucune sonde
ne tournait :
- l'écho décidait sans verdict ;
- `NUDGE_IN_TOOL` gardait la conversation au Go (`facadeChatBlocker`).
## Le lot
`rust/loki-rs/src/chat/tplprobe.rs` porte la sonde entière :
- les requêtes au moteur local (`/health`, `/props`, `/apply-template`, avec la
clé du moteur et les relances sur 503) ;
- les rendus synthétiques et leurs verdicts ;
- le cache par build, modèle, gabarit et forme ;
- le dernier verdict, global et par forme ;
- la ligne de journal ;
- le déclenchement borné (une sonde en vol au plus, une par minute) ;
- le relevé du rendu et sa comparaison.
Avec `LOKI_CHAT=rust` :
- **la boucle du tour** signale chaque complétion lue en entier sur le moteur
local, outils ni coupés ni neutralisés (`TurnHost::completed`). L'hôte lance
la sonde pour le fil principal seulement : ni tâche ni sous-agent ;
- **la politique d'écho** lit le dernier verdict ;
- **`NUDGE_IN_TOOL`** est porté (`nudge_in_tool_key`, `nudge_into_tool`,
`TurnHost::prefix_unstable`). Il ne garde plus la conversation au Go ;
- **le résumé des performances**, que sert encore le Go, relit le verdict de la
façade (`/internal/facade/v1/chat/tpl`, `facadeTplCaps`).
## Parité
`internal/loki/parity_tplprobe_test.go` enregistre chaque échange de la sonde
du Go avec un faux moteur : méthode, chemin, clé, corps envoyé, réponse.
Les comportements de moteur couverts :
- gabarit Qwen3, gabarit qui garde tout, raisonnement jamais rendu, amorce
ignorée ;
- 404, exception du gabarit, clé exigée (inconnue ou connue) ;
- 503 relancés ou persistants, 429 ;
- `/props` illisible, moteur pas prêt, réponse sans prompt ;
- rendus incohérents, preset externe.
Le journal contient aussi :
- le verdict, sa ligne de journal, `tplCapsCurrent` et `tplCapsFor` ;
- le cache : même moteur, gabarit changé, build changé ;
- le relevé du rendu sur trois gabarits, et une erreur ;
- neuf comparaisons de relevés ;
- l'empreinte de six formes.
`tests/tplprobe.rs` rejoue chaque échange contre le port. Le faux moteur rend
les réponses enregistrées et exige des requêtes identiques, octet pour octet.
Il vérifie aussi le déclenchement borné.
Autres tests :
- `tests/lot6.rs` vérifie la sonde lancée après un vrai tour de la façade ;
- les tests de la boucle vérifient le rappel dans le résultat d'outil et les
complétions signalées ;
- `TestFacadeSondeDeGabarit` vérifie, côté Go, que le résumé des performances
relit le verdict de la façade.
@@ -0,0 +1,130 @@
# Lot 24 — panneau « moteur de l'image » tenu par la façade
## Contexte
Le panneau Moteur permet de mettre à jour llama.cpp sans reconstruire
l'image. Le moteur est pris dans l'image de conteneur officielle
(`ghcr.io/ggml-org/llama.cpp:server-cuda-b#####`), dont on ne télécharge que
les couches du sommet, celles qui portent `/app`. Trois fichiers du Go le
servent :
- `web_engine.go` : statut, dernier build publié, mise à jour, bascule,
suppression ;
- `web_engine_reco.go` : version recommandée, risque de rendu du
raisonnement (défaut de b10763), retour à la version précédente,
vérification du rendu du gabarit avant/après la bascule ;
- `backend_engine_oci.go` : dialogue avec le registre, extraction des
couches, essai à blanc.
La mise à jour est un job, le job des moteurs que la façade tient depuis le
lot 22. Le risque de rendu et la vérification du rendu reposent sur la sonde
de gabarit, portée au lot 23.
## Le lot
Avec `LOKI_ENGINE_IMAGE=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la
façade (`rust/loki-rs/src/engineimg`) sert les routes suivantes :
| Route | Rôle |
| --- | --- |
| `/api/engine` | statut : binaire, build, source, variante, versions installées, avis, encart « version recommandée », version précédente, relevé du rendu |
| `/api/engine/check` | dernier build publié pour la variante, comparé au courant |
| `/api/engine/update` | job : recherche, téléchargement des couches du sommet, essai à blanc, REASONING_PRESERVE=off si demandé, bascule, ménage, vérification du rendu |
| `/api/engine/use` | bascule vers une version installée ou vers le moteur de l'image |
| `/api/engine/remove` | suppression d'une version installée (refusée pour celle qui tourne) |
| `/api/engine/plan` | sur clic : version visée (dernière ou recommandée) et risque de rendu |
| `/api/engine/rollback` | retour au moteur d'avant la dernière bascule |
Sans les backends, loki-rs ne passe pas la variable au Go : le panneau
reste au Go et le dit au démarrage. `/api/engine/cmdline` reste au Go.
La sonde de gabarit est celle de la conversation quand la façade la tient
(`LOKI_CHAT=rust`) : le relevé du rendu rejoue alors la forme de ses vraies
requêtes. Sinon, la façade a sa propre sonde, qui sonde une requête nue.
Pour REASONING_PRESERVE=off posée dans le preset actif, la façade réutilise
l'enregistrement du lot 15 (`save_applying`). Le service est arrêté puis
relancé comme au lot 22 : par la façade si elle pilote le moteur, sinon par
le Go (`/internal/v1/service/action`).
Côté Go, `facade_engineimg.go` relaie à la façade les routes reçues par le
tunnel.
## Parité
`internal/loki/parity_engineimg_test.go` (Linux) tire du Go un journal
rejoué par `tests/engineimg.rs`. Il tourne contre :
- un faux registre OCI : jeton, index multi-architecture, manifeste annoté,
couches .tar.gz ;
- un faux moteur local (le `fakeTpl` du Go, porté) pour la sonde et le rendu.
Le journal couvre :
- **le statut** : moteur de l'image, téléchargé ou compilé, avec la version
précédente (absente quand c'est le moteur courant), le relevé du rendu,
sans moteur d'image, sans aucun moteur ;
- **le dernier build** : publié, sans numéro, ou refusé (jeton refusé ou
illisible, manifeste refusé ou illisible, aucune image pour
l'architecture) ;
- **le plan** :
- dernière version (avec ou sans numéro) ou recommandée (build trop
ancien, tag figé absent, sans numéro), et déjà à jour ;
- chaque verdict du risque de rendu : moteur arrêté, gabarit vide,
REASONING_PRESERVE posée, EXTRA_ARGS, gabarit sans les variables, gabarit
qui garde la réflexion, build antérieur au défaut, preset externe ;
- **la mise à jour** :
- couches du sommet vers la base ; une couche du dessus masque celle du
dessous ; liens absolus ramenés dans `/app` ; marqueurs de suppression et
sous-dossiers ignorés ; couche sans fichier du moteur ; couche du runtime
ignorée ; phase du téléchargement ;
- essai à blanc ;
- risque de rendu rappelé dans le journal, établi ou impossible à vérifier
(moteur local arrêté) ;
- REASONING_PRESERVE=off : posée dans le preset actif ou dans la
configuration, déjà posée, ou inconnue du moteur ;
- ménage : versions triées par build, dossier sans moteur épargné, version
précédente gardée ;
- rendu comparé avant/après : changé, identique, ou impossible sur le
nouveau moteur ;
- déjà à jour, corps illisible ;
- **les échecs** :
- optimisation en cours, version inconnue, image sans serveur ou sans
version annoncée, manifeste sans couche, runtime au sommet, couche
introuvable, tag illisible ;
- moteur qui ne démarre pas (12 dernières lignes de sa sortie) ou qui ne
voit aucune carte ;
- **les bascules** : vers une version installée, vers l'image, vers une
version absente, pendant une optimisation ; retour, deux fois, et en GET ;
- **la suppression** : version (`../` neutralisé), version absente, moteur
utilisé ;
- **après chaque appel** : BIN, REASONING_PRESERVE, `engine/previous-bin` et
l'arborescence de `engine/`.
Autres tests :
- la façade vérifie le service arrêté puis relancé autour d'une mise à jour
et redémarré par une bascule (`service_arrete_puis_relance`), le relevé
« non vérifié à temps », le 503 sans backends, les variantes et les
vérifications périmées ;
- le Go (`TestFacadeMoteurImage`) vérifie que les sept routes partent à la
façade, et elles seulement.
## Mutations
74 mutations du port Rust, toutes tuées sauf trois, équivalentes :
- la garde « moteur d'image absent » de la source : `same_path` ne
rapproche jamais un chemin vide ;
- l'essai à blanc qui relance l'inventaire des cartes quand l'ancien moteur
est le nouveau : même résultat ;
- une vérification du rendu périmée qui continue d'attendre son moteur : son
relevé est de toute façon écarté au rangement.
## Écarts assumés
- Les erreurs réseau (registre injoignable) gardent le texte de `reqwest`.
- Un manifeste dont un champ a le mauvais type ne donne pas le message du
Go : le nom Go du type anonyme n'est pas reproduit.
- La vérification du rendu attend son délai à la cadence choisie (5 s), sans
le contexte Go qui coupe aussi une requête en cours au bout de 20 min.
@@ -0,0 +1,121 @@
# Lot 25 — Strata tenu par la façade
## Contexte
Strata est le deuxième moteur de Loki. Il répartit les experts d'un MoE de
125 B (famille Qwen3.8-Flash-Next) entre VRAM, RAM et disque. Le moteur est
le paquet **figé** publié par AJEAN : les sources et le binaire Linux CUDA 12,
vérifiés par SHA-256. Un preset le choisit (`ENGINE=strata`).
`backend_strata.go` sert trois routes :
- `/api/strata` : la machine (cartes, RAM, disque, Python), ce qui y tient,
la recommandation par version et les modèles installés, avec leur
configuration de lancement en clair ;
- `/api/strata/settings` : les réglages d'un modèle installé. S'il est en
service, la configuration est réappliquée et le moteur relancé ;
- `/api/strata/install` : un job des moteurs. Il télécharge et vérifie le
paquet, lance l'installeur de Strata, écrit le preset et bascule dessus.
Le lancement (`loki serve` d'un preset Strata) vit aussi dans ce fichier.
## Le lot
Avec `LOKI_STRATA=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la façade
(`rust/loki-rs/src/strata`) sert les trois routes :
- **la décision pure** (`strata/mod.rs`) :
- placement des experts : drop, ram, arena ou disk, selon la RAM
disponible, la carte d'appoint et `experts.bin` ;
- configuration de lancement : réglages validés, carte d'aide, mode des
experts, réglages du preset ;
- ce qui tient sur la machine, la recommandation, les réglages, les noms ;
- **la machine** (`strata/api.rs`) :
- cartes via `nvidia-smi` (la sonde des backends) ;
- Python 3.10+ et son module venv ;
- RAM totale et disponible (`/proc/meminfo`) ;
- RAM du moteur Strata en marche (arbre du service dans `/proc`) ;
- place libre ;
- **l'installation** : le job des moteurs. La sortie de l'installeur est
découpée en lignes comme le `sinkWriter` du Go : une barre `\r` n'est
montrée que toutes les 10 s. La bascule finale passe par le Go
(`/internal/v1/preset/apply`, mode `switch`), qui applique le preset et
relance le service.
Sans les backends, loki-rs ne passe pas la variable au Go et Strata reste au
Go. Le lancement reste au Go (`loki serve`, que `loki-rs engine serve` lui
délègue pour un preset Strata).
Côté Go, `facade_strata.go` relaie à la façade les routes reçues par le
tunnel.
## Correction du Go
`/api/strata/settings` décidait si le preset était en service **après** y
avoir écrit les nouveaux réglages. L'empreinte du nouveau contenu ne
correspondait alors plus à la configuration active : changer un réglage du
modèle en service ne l'appliquait jamais, et le moteur n'était pas relancé.
« En service » se décide désormais avant l'écriture, comme dans
`SavePresetApplying`. Le port Rust suit.
## Parité
`internal/loki/parity_strata_test.go` (Linux x86_64) tire du Go un journal
rejoué par `tests/strata.rs`. Pour le tirer, le Go reçoit des crochets de
test : `strataReleaseBase` (variable), `meminfoPath`, `strataDiskFree` et
`serviceActionOSFn`. Le journal couvre :
- **la machine** :
- sans carte, nvidia-smi en panne ou vide, carte trop ancienne ou trop
petite, carte d'aide trop ancienne ;
- trois cartes de même VRAM : la plus récente devant (capacité de calcul
arrondie au dixième) ;
- Python absent, en panne, illisible, trop ancien, sans venv ;
- 16, 47 ou 64 Go de RAM, meminfo sans MemAvailable ;
- disque presque plein ou juste trop petit (marge de 5 Go), données déjà
présentes ;
- **les réglages** :
- GET, corps illisible, preset vide ou avec chemin, preset absent ou
llama.cpp ;
- hors limites, experts inconnus ;
- preset inactif ; preset actif (appliqué et relancé), et pendant une
optimisation ;
- **l'installation** :
- GET, corps illisible, quant inconnu, optimisation en cours ;
- machine sans carte, modèle qui ne tient pas ;
- release introuvable, moteur altéré (empreinte) ;
- installation complète, puis réinstallation (sources déjà là et
décompressées, moteur téléchargé périmé retéléchargé) ;
- installeur en échec, installeur sans configuration ;
- **les modèles installés** :
- détails de lancement du modèle actif et de modèles inactifs (experts
sur disque ou en RAM, vision coupée, sans carte d'aide) ;
- mode du dernier lancement ;
- `experts.bin` avec le moteur du paquet ou d'un autre paquet ;
- RAM juste sous ou au-dessus du seuil du mode hors RAM, mode hors RAM
coupé dans le preset ;
- configuration de lancement disparue ou illisible ;
- **après chaque appel** : les actions du service, les clés de la
configuration et l'arborescence de `presets/` et `strata/` (sans le
contenu des archives).
Autres tests :
- les tests du Go (`backend_strata_test.go`), portés (`strata/tests.rs`) :
configuration de lancement, placement des experts, recommandation,
réglages, noms ;
- la RAM du moteur en marche, le découpage en lignes et les nombres écrits
à la Go ;
- le Go (`TestFacadeStrata`) : les trois routes partent à la façade, et
elles seules.
## Mutations
71 mutations du port Rust, toutes tuées (les dix survivantes du premier
passage l'ont été par les étapes et les tests ajoutés ci-dessus).
## Écarts assumés
- Les erreurs réseau (release injoignable) gardent le texte de `reqwest`.
- Une copie du moteur qui échoue (`prebuilt/`) ne donne pas exactement le
message du Go, qui ouvre la source puis la destination.
@@ -0,0 +1,193 @@
# Lot 26 — Tâches planifiées tenues par la façade
## Contexte
Une tâche planifiée tourne seule, sans utilisateur en face. Il en existe
deux sortes :
- **une tâche IA** : une consigne, exécutée par l'agent dans un fil
éphémère ;
- **une tâche « script seul »** : un script du dossier protégé
(`$LOKI_HOME/scripts`), lancé sans modèle. Sa sortie devient le
compte-rendu.
Le Go les tient dans plusieurs fichiers :
- `tasks.go` : la base (`bkTasks`) et l'interrupteur maître ;
- `tasks_cron.go` : les fréquences ;
- `tasks_sched.go` : le planificateur, un tic par minute et une tâche due
par tic ;
- `tasks_run.go` et `tasks_script.go` : l'exécution ;
- `web_tasks.go` : sept routes.
Les fréquences prennent quatre formes :
- `@every 2h` : intervalle glissant d'au moins une minute ;
- `@every 3d@9:30` : tous les N jours, à une heure murale ;
- `@once 2026-10-01 16:54` : une seule fois ;
- une expression cron à cinq champs.
L'heure murale est celle du fuseau de la tâche. Sans fuseau, ou avec un
fuseau inconnu, c'est l'heure locale du serveur.
## Le lot
Avec `LOKI_TASKS=rust` (expérimental), la façade (`rust/loki-rs/src/tasks`)
sert les routes `/api/tasks*` et fait tourner le planificateur, que le Go ne
démarre plus. Elle exécute elle-même les tâches script :
- le shell de l'agent (`run_shell`), dans le dossier de la discussion
ouverte ;
- une limite de 10 min ;
- un arrêt par `/api/tasks/stop`.
Une tâche IA passe par le verrou de génération et les outils de l'agent, qui
restent au Go. La façade la lui fait exécuter par `/internal/v1/tasks/run`,
que le Go traite comme son propre planificateur l'aurait fait :
- preset épinglé ;
- projet forcé ;
- `RunAutonomous` ;
- état persisté ;
- notification.
Moteur occupé ou modèle pas prêt : la tâche ne tourne pas, son état ne bouge
pas, et le tic suivant la réessaie. Le Go rend la main à la fin du passage.
Les notifications des tâches script partent par la façade si elle tient les
notifications, sinon par le Go (`/internal/v1/push/send`).
Les fréquences demandent l'heure murale d'un fuseau IANA. `src/tz.rs` porte
ce que le Go fait de la zoneinfo du système :
- lecture des TZif v1 et v2, et de la règle POSIX qui prolonge les
transitions ;
- `time.Local` (`$TZ`, `/etc/localtime`) ;
- `time.Date`, avec la normalisation des débordements ;
- les heures qui sautent ou qui se répètent, résolues comme le Go.
`tasks/cron.rs` porte `time.ParseDuration` du Go, messages d'erreur compris.
Côté Go :
- `facade_tasks.go` : le relais des routes reçues par le tunnel et les deux
routes internes ;
- `web_server.go` : le planificateur n'est pas démarré ;
- `tasksNow` : l'heure des tâches, remplaçable en test.
## Correction de la façade
Les appels de la façade à l'API interne du Go étaient tous bornés à 10 s. Un
outil exécuté par le Go (`/internal/v1/tool/exec`) qui dure plus longtemps
était coupé :
- une page web lente ;
- le navigateur ;
- un serveur MCP tenu par le Go.
Le résultat partait en erreur alors que l'outil continuait. Ces appels sont
désormais bornés à 30 min. Une tâche IA exécutée par le Go est attendue sans
autre limite que 24 h.
## Ce que les mutations ont corrigé dans le port
Le premier passage des mutations a laissé survivre des cas que le journal ne
voyait pas. Confrontés au Go, plusieurs étaient de vrais écarts :
- **dates lointaines** : un instant en nanosecondes sur 64 bits s'arrête en
2262. Le Go accepte `@once 2400-02-29 10:00`, ou un intervalle en jours
qui mène au-delà. Les instants des fréquences passent sur 128 bits ;
- **intervalle en jours démesuré** : `@every 200000d` déborde dans le Go
(`time.Duration`), qui rend une date passée. Le port déborde de même ;
- **l'heure qui saute** : `@every 1d@02:30` à Paris, le jour du passage à
l'heure d'été, tombe à 3 h 30. Le Go avance ensuite de jour en jour depuis
ce candidat (`AddDate`) et reste à 3 h 30. Le port repartait de 2 h 30 ;
- **compte-rendu tronqué** : le Go coupe à 4000 octets, au milieu d'un
caractère s'il le faut, et chaque octet orphelin devient U+FFFD à
l'encodage JSON. Le port reculait à une frontière de caractère ;
- **nom de fuseau** : le Go refuse tout nom qui contient `..`, pas seulement
un composant `..`.
## Parité
`internal/loki/parity_tasks_test.go` (Linux) tire du Go un journal rejoué
par `tests/tasks.rs`. L'heure des tâches est fixée pas à pas ; les
identifiants tirés au hasard deviennent `{{ID1}}`, `{{ID2}}`… dans l'ordre où
ils apparaissent. Le journal couvre :
- **l'écran** :
- scripts du dossier (sous-dossiers compris) ;
- mode agent, interrupteur maître (rangé à « 0 » : pas suspendu) ;
- projets, mémoire coupée, internet armé ;
- la tâche en cours pendant un script ;
- **l'enregistrement** :
- corps illisible, sans nom, sans consigne ;
- script absent, dossier, remontée, chemin absolu, séparateur Windows,
caractère interdit ;
- chaque erreur de fréquence : vide, intervalle trop court, illisible ou
sans unité, jours ou heure invalides, cron à 4 champs, hors bornes, en
lettres, plage inversée, pas nul, jamais satisfait, date illisible ou
déjà passée ;
- chaque forme de fréquence, dans plusieurs fuseaux : Paris (dont l'heure
qui saute), New York, Calcutta, fuseau inconnu ;
- id inconnu gardé, projet inconnu remplacé par l'actif ;
- modification avec ou sans changement de fréquence ;
- **la bascule, l'interrupteur maître, la suppression** ;
- **les lancements** :
- tâche absente ;
- tâche IA avec moteur occupé (409) ou modèle pas prêt (503) ;
- script en succès, en échec, arrêté à la main ;
- script qui échoue après un succès, puis se rétablit ;
- tâche script rangée sans script, au nom refusé, au script disparu ;
- **le planificateur** :
- tic suspendu, tic pendant une optimisation ;
- une tâche IA due qui attend le modèle ;
- une tâche due par tic, dans l'ordre des noms ;
- la tâche « une fois » qui s'éteint ;
- une tâche sans prochain passage (posé sans la lancer) ;
- une fréquence devenue invalide ;
- deux tâches de même nom (rangées par id).
Après chaque étape, le journal enregistre les tâches en base.
Autres tests :
- fuseaux et fréquences confrontés au Go :
- Paris aux deux changements d'heure, New York, Calcutta ;
- avant la première transition, pile à une transition, la table avant la
règle prolongée ;
- `$TZ` (vide, « : », chemin, nom), `/etc/localtime` ;
- noms refusés, « UTC » sans base ;
- mois hors bornes, années bissextiles, mois de 30 jours ;
- `ParseDuration` et ses débordements, cron (plages, pas, bornes, à 78 et
364 jours) ;
- le compte-rendu tronqué, les noms de script ;
- l'amorçage au démarrage (tâches actives sans prochain passage) ;
- la durée d'un passage, à une horloge qui avance ;
- la tâche IA en cours vue par l'écran ;
- la tâche IA lancée à la main part au Go (`tache_ia_executee_par_le_go`) ;
- le planificateur tourne à son pas sans demande ;
- sans `LOKI_TASKS`, les routes partent au Go ;
- le Go (`TestFacadeTaches`) :
- les sept routes partent à la façade, et elles seules ;
- `/internal/v1/tasks/run` sur une tâche IA sans modèle (occupée, rien
d'enregistré) et sur une tâche script ;
- `/internal/v1/push/send`.
## Mutations
79 mutations du port Rust, dont 77 tuées. Les survivantes du premier passage
l'ont été par les tests et les étapes ajoutés ci-dessus. Restent deux
équivalentes :
- le refus d'un nom de script absolu **après** nettoyage : sous Unix, un nom
qui ne commence pas par `/` ne le devient pas (le contrôle du Go vaut pour
Windows) ;
- `web_on` sans `internet_enabled` : la joignabilité refuse déjà un moteur
Crawl4AI sans adresse, seul cas où les deux diffèrent.
## Écarts assumés
- Pendant une tâche IA, le Go force le projet de la tâche. Une tâche
enregistrée sans projet à ce moment-là prend, côté façade, le projet actif
rangé.
@@ -0,0 +1,151 @@
# Lot 27 — Banc d'essai tenu par la façade
## Contexte
Le banc d'essai mesure le moteur local tel que le chat s'en sert, sur
`/v1/chat/completions`, avec le gabarit, le raisonnement et
l'échantillonnage du preset (`llm_bench.go`, `llm_bench_job.go`). Il a deux
modes :
- **rapide** : un préchauffage jeté, puis la ligne courte (~2000 jetons de
prompt, ~300 générés) ;
- **complet** : la ligne courte, puis une discussion à profondeur réelle —
un prefill à froid de D jetons, et trois tours qui y ajoutent chacun ~2048
jetons neufs. La reprise du cache est lue dans la réponse, jamais
supposée.
Il tourne en tâche de fond derrière quatre routes :
- `POST /api/bench` lance (202 et un id) ;
- `GET /api/bench/status` dit la phase, puis le résultat ou l'erreur ;
- `POST /api/bench/cancel` annule ;
- `GET /api/bench/last` rend la dernière mesure enregistrée.
Pendant la mesure, le bench tient le verrou de génération de la
conversation. Un message, une tâche planifiée ou un client `/v1` reçoivent
le refus du bench. Seule une mesure complète est enregistrée : la
dernière, et celle du preset actif.
## Le lot
Avec `LOKI_BENCH=rust` (expérimental), la façade (`rust/loki-rs/src/bench`)
sert les quatre routes et déroule le protocole contre le moteur :
- `bench/mod.rs` : le protocole, porté du Go à l'octet près. On y trouve :
- les mêmes corpus : la prose du Go et l'interface web embarquée, de la
même source ;
- les mêmes requêtes ;
- le même calcul de profondeur, de reprise du cache et de débit ;
- les mêmes messages d'erreur ;
- `bench/job.rs` : le job, les routes et l'enregistrement (`last_bench`,
`bench_presets`, avec l'empreinte de configuration du Go).
Le verrou de génération reste celui de la conversation du Go. La façade le
prend par le **bail du moteur**, avec le motif `bench` :
- le Go note alors `benching` et donne aux autres le refus du benchmark ;
- changer de discussion ne rompt pas le bail ;
- `benchRunning()` le voit aussi : les clients `/v1`, le préchauffage et
l'optimiseur attendent ;
- un stop du chat côté Go (signalé au renouvellement), un Reset ou un bail
perdu annulent le bench.
Le Go prête aussi à la façade :
- **le travail annexe** (`/internal/v1/engine/sidejob`) : à la fin, le slot
du bench est effacé si c'est sans risque, comme pour un bench du Go ;
- **la télémétrie** (`/internal/v1/bench/perf`) : la trace du bench dans
`perf_log`.
Le port, la clé de l'API locale et le second slot (`SIDE_SLOT`) viennent de
`/internal/v1/oai/route`. Les niveaux de raisonnement appris sont ceux de la
conversation de la façade si elle la tient, sinon ceux du bench.
La ligne de commande (`loki bench`) reste au Go.
## Correction des tests du Go
Trois tests du préchauffage échouaient dix minutes par jour, avant minuit :
le préchauffage se tait alors (`prewarmNearMidnight`), et ils lisaient
l'heure réelle. L'heure de ce garde-fou devient remplaçable (`prewarmNow`),
et les tests la fixent à midi.
## Parité
`internal/loki/parity_bench_test.go` (Linux) tire du Go un journal rejoué
par `tests/bench.rs` contre le même faux moteur (`fakeBenchEngine`, porté).
Chaque requête reçue par le moteur est comparée ; les longs textes (corpus)
sont réduits à leur taille et à leur empreinte. Le journal couvre :
- **les routes** : statut et dernière mesure sans bench, GET refusés,
annulation sans bench ;
- **les refus au lancement** : preset externe, optimisation en cours, modèle
pas prêt, génération en cours, second lancement pendant un bench ;
- **les échecs dans le job** : moteur occupé par un autre (`/slots`), preset
Strata ;
- **les mesures** :
- mode rapide, puis mode complet (profondeur, trois tours, reprise du
cache, brouillon) ;
- contexte trop petit (profondeur sautée), contexte inconnu du moteur
(celui du preset) ;
- paramètres bornés, petits paramètres, corps illisible ;
- **les échecs d'une mesure** :
- préchauffage, ligne courte, prefill à froid ou tour en profondeur en
erreur ;
- réponse sans timings ;
- **le raisonnement** : niveau refusé par le gabarit puis rejoué, puis le
niveau appris envoyé d'emblée ;
- **budget et annulation** : budget d'une requête dépassé (profondeur
partielle, non enregistrée), annulation pendant la profondeur ;
- **après chaque étape** : la dernière mesure et les mesures par preset.
Autres tests :
- ceux du Go (`llm_bench_test.go`), portés (`bench/tests.rs`) : profondeur,
types de cache, requête, morceaux du corpus, reprise du cache, relectures
du disque, empreinte de l'enregistrement ;
- la réponse du moteur (timings, cache, brouillon, nombres illisibles),
`Duration.String` du Go, les flottants écrits à la Go ;
- la façade : le bail « bench », le travail annexe (slot servi ou non), la
trace, la clé de l'API locale, le second slot, le stop du chat et le bail
perdu, les routes sans la façade ;
- le Go (`TestFacadeBench`) :
- les quatre routes partent à la façade, et elles seules ;
- le bail « bench » : refus, bascule de discussion, libération,
expiration ;
- le travail annexe et la trace.
## Mutations
84 mutations du port Rust, dont 81 tuées. Les survivantes du premier passage
l'ont été par les tests ajoutés :
- `bench/tests.rs` : la tranche pile à la fin du corpus, un tour sans
attendu à côté d'un tour mesuré, les débits nuls rendus par le moteur
(recalculés), le niveau de raisonnement rejoué une seule fois, le
préchauffage et la ligne courte d'un `run` rapide (paramètres par défaut,
64 jetons de préchauffage au moins, code ajouté juste au-delà de la
prose), la profondeur arrêtée (contexte plein, corpus épuisé, réponse du
tour précédent reprise, note de reprise selon le modèle hybride) ;
- `tests/bench.rs` : un second lancement pendant la prise du bail, le preset
relu au départ du job (devenu externe, ou optimiseur lancé entre-temps),
la mesure enregistrée sous le preset ACTIF ;
- le journal : un prompt juste au-delà de la prose.
Restent trois équivalentes :
- l'annulation vérifiée avant d'envoyer : déjà annulé, le `select!` rend
l'annulation à son premier tour, avant que la requête ne parte ;
- la progression d'un job remplacé : un seul bench à la fois, un job fini ne
rapporte plus rien ;
- le PID nul du moteur : `/proc/0` n'existe pas, la lecture échoue de toute
façon.
## Écarts assumés
- Une erreur réseau vers le moteur garde le texte de `reqwest` après le
préfixe du Go (`Post "url": …`).
- Un niveau de raisonnement appris par le bench de la façade n'est pas
transmis au chat du Go, quand le Go tient la conversation (et
inversement) : la première requête paie le refus, puis la traduction est
retenue.
@@ -0,0 +1,93 @@
# Lot 7 — outils de l'agent exécutés par la façade
## Contexte
Lot 6 : quand la façade tient la conversation (`LOKI_CHAT=rust`), la boucle du
tour est en Rust mais chaque appel d'outil repart au Go
(`/internal/v1/tool/exec`). Lot 7 : porter les outils eux-mêmes, en retirant
les appels au Go un par un. Rien ne change sans `LOKI_CHAT=rust` : la boucle
Go exécute ses outils comme avant.
## Étape 1 — fichiers et shell (fait)
Exécutés par la façade (`rust/loki-rs/src/tools/`) : `read`, `grep`, `glob`,
`write`, `edit`, `bash`, `ask`, avec les mêmes garde-fous dans le même ordre
(commande catastrophique, `&` final en mode Code, bac à sable du mode Code,
dossier mémoire interdit, « lu avant d'écrire »), le même diff pour l'UI et le
même nettoyage de la sortie du shell (`| tail -N`, ANSI, fin bornée).
L'état partagé reste au Go, en un seul exemplaire, derrière trois routes
internes (`web_internal_toolstate.go`) :
| Route | Rôle |
|---|---|
| `POST /internal/v1/tool/context` | dossier de travail (`agentCwd`), dossier mémoire, cible distante |
| `POST /internal/v1/tool/tracker` | `read` : lecture notée ; `check` : refus éventuel avant écriture |
| `POST /internal/v1/tool/wrote` | après une écriture : note du suivi, bloc des diagnostics LSP |
Le suivi reste au Go parce que les sous-agents et les tâches planifiées, qui y
tournent, le lisent et l'écrivent aussi ; les serveurs LSP y vivent.
Repli sur le Go : outil non porté ; `write`/`edit`/`bash` quand l'agent vise
un poste distant (une commande catastrophique est refusée avant, ici) ;
contexte indisponible.
Le jeton de l'API interne est retiré de l'environnement du shell de l'agent.
### Parité
`internal/loki/parity_tools_test.go` passe 58 appels à `execTool` sur une
arborescence de test et range le journal (libellés, résultats, diffs,
questions, arborescence finale) dans `rust/loki-rs/tests/parity_tools/` ;
`rust/loki-rs/tests/tools.rs` rejoue les mêmes appels dans `exec_local` et
retrouve le même journal. Le test Go échoue si le comportement Go change
(`LOKI_PARITY_UPDATE=1` pour régénérer, puis le test Rust dit si le port suit).
### Écarts assumés
- Motif `grep` invalide : le message d'erreur est celui du moteur d'expressions
de Rust (crate `regex`), pas celui de Go.
- Classes `\w`, `\b`, `\s`, `\d` d'un motif `grep` : Unicode en Rust, ASCII en
Go (un mot accentué y est un mot entier).
- Octets hors UTF-8 dans un fichier lu ou cherché : remplacés par U+FFFD par
séquence invalide (Go : par octet, à l'encodage JSON). `edit` travaille sur
les octets et laisse intact ce qu'il ne remplace pas.
- `mkdir` en échec : le chemin cité est le dossier parent visé, pas le
composant qui a échoué.
## Étape 2 — boucle du contrat du mode Code (fait)
`codeVerifyLoop` porté (`rust/loki-rs/src/chat/verify.rs`) : à la fin d'un
tour de build en mode Code, relances du builder sur les critères encore
ouverts, passe de vérification sur un contexte isolé (`POST
/internal/v1/turn/verifier` : prompt du rôle et faits de la machine, outils
fermés, rôle `verifier` qui seul peut marquer `passed`), corrections, abandon
dit dans le fil après deux corrections. Mêmes deltas que le Go (`role`,
`verify_done`, mesures `ctx_isolated`). Les critères sont lus en base
(`chat/crit:<id>`) ; l'outil `criteria` reste au Go. Toute compaction en mode
Code rend leur état au résumé, comme `compactMessages`.
Écart assumé : l'isolation du slot du moteur après la vérification
(`engineSideJob`) reste au Go, qui ne voit pas passer les requêtes de la
façade.
## Étape 3 — git (fait)
`git_status`, `git_diff`, `git_clone` (`rust/loki-rs/src/tools/git.rs`) :
dépôt deviné comme le Go (sous-dossier demandé, racine, unique sous-dossier
dépôt), sorties mêlées et bornées, jamais d'invite, clone limité à https:// et
git@hôte: et rangé dans le dossier de la discussion. Toujours sur cette
machine, cible distante ou non (comme le Go). Journal de référence
`tests/parity_tools/git.json` (`TestPariteOutilsGit`, deux dépôts, aucun appel
réseau), rejoué par `tests/tools_git.rs`.
## Étapes suivantes
1. Web (`web_search`, `web_open`, `web_read`, `web_grep`, `web_images`) : le
cache de pages et la conversion HTML → Markdown (bibliothèques Go) ne se
reproduisent pas à l'octet ; à porter avec un journal de référence
structurel plutôt qu'exact.
2. Shell d'arrière-plan (`bash_bg`, `bash_tail`) : ses travaux sont lus par
l'optimiseur et la suppression d'une discussion, au Go.
3. Mémoire (`mem_*`, `recall*`, `tracker`) : avec le portage du chiffrement.
4. Tâches, navigateur (CDP), MCP, sous-agents, critères.
@@ -0,0 +1,101 @@
# Lot 8 — routes des modèles tenues par la façade
## Contexte
Les modèles `.gguf` vivaient dans le Go :
- la liste du sélecteur (`handleModels`, `backend_models_refs.go`) ;
- les dossiers de modèles (`backend_model_dirs.go`) ;
- la suppression (`deleteModelFile`) ;
- les téléchargements (`backend_models.go`, `backend_models_shards.go`) ;
- le catalogue Hugging Face (`backend_hf.go`, `web_hf.go`, `backend_catalog.go`).
Lot 8 : la façade tient tout cela quand `LOKI_MODELS=rust` (expérimental ;
sans la variable rien ne change, tout part au Go).
## Ce que sert la façade
| Route | Module | Rôle |
|---|---|---|
| `GET /api/models` | `models.rs` | `.gguf` des dossiers déclarés (tranches 2..N exclues), taille de la famille, tranches manquantes, qui les référence (presets, configuration en service) |
| `POST /api/models/delete` | `models.rs` | refus si en service ; 409 avec la liste des presets sans `force` ; le modèle part avec toutes ses tranches, octets libérés rendus |
| `GET\|POST /api/models/dirs` | `models.rs` | dossiers de modèles, nombre de modèles, espace libre ; ajout (dossier existant) et retrait |
| `POST /api/models/download/probe` | `downloads.rs` | taille du modèle (toutes ses tranches), espace libre, « ça tient ? », ou « déjà là » avec la valeur de `MODEL=` |
| `POST /api/models/download` | `downloads.rs` | lance en arrière-plan ; 409 si déjà en cours ; « déjà là » si toutes les tranches sont présentes |
| `GET /api/models/download/status` | `downloads.rs` | état de chaque téléchargement de ce lancement |
| `POST /api/models/download/cancel` | `downloads.rs` | annule ; ce que le téléchargement a écrit est effacé |
| `GET /api/hf/search` | `hf.rs` | dépôts GGUF, matériel local joint |
| `GET /api/hf/files` | `hf.rs` | modèles, projecteurs et poids MTP d'un dépôt, verdict mémoire de chaque modèle (contexte du preset actif, projecteur du dépôt) |
| `GET\|POST /api/hf/token` | `hf.rs` | jeton vérifié auprès de Hugging Face avant d'être enregistré, jamais rendu en clair |
Mêmes contrats (corps, codes, messages) et même mécanique que le Go, entre
autres :
- téléchargement :
- lien de page Hugging Face (`/blob/`) converti en lien direct ;
- modèle en tranches rapatrié en entier ;
- place disque vérifiée sur le total, avec 256 Mio de marge ; jamais de
refus sur FUSE, overlayfs, NFS, SMB, Ceph ou GlusterFS ;
- plages d'octets en parallèle (`LOKI_DL_CONNS`) et reprise d'une
connexion coupée ;
- jeton envoyé aux seuls hôtes Hugging Face ;
- catalogue : réponses mises en cache (60 s pour une recherche, 10 min pour un
dépôt), cache vidé à l'enregistrement d'un jeton ;
- `.part` orphelins vidés au démarrage — par la façade : le Go ne le fait
plus, il effacerait un téléchargement qu'elle mène.
## Côté Go (`facade_models.go`)
Le tunnel du relais et la redistribution E2E arrivent au Go sans passer par
la façade : `withFacadeChat` lui remet ces routes, authentifiées par le
jeton interne, comme celles du chat. Façade injoignable : 502 « modèles
indisponibles : … ».
La façade transmet `LOKI_MODELS=rust` et son adresse au Go qu'elle lance.
## Écarts assumés
- Une source qui ignore les plages n'est plus lue en entier pendant la sonde.
- Erreurs de transport (réseau, TLS) au format de reqwest.
- Message d'un corps JSON illisible : celui de serde (l'interface n'en envoie
jamais).
- Hôte d'un lien collé en majuscules converti comme les autres.
- Espace libre et mémoire vive inconnus hors Unix/Linux (la façade n'est
livrée que pour Linux).
- `/api/models/download/status` trié par date de début.
- Nombres flottants du matériel écrits `24.0` (le Go écrit `24`) : même
valeur JSON.
## Parité
Journaux tirés du Go et rejoués en Rust (`LOKI_PARITY_UPDATE=1` pour
régénérer) :
- `internal/loki/parity_dl_test.go` → `tests/parity_models/download.json` →
`tests/downloads.rs` : lien normalisé, famille de tranches,
`Content-Range`, messages de refus, hôtes et dépôts Hugging Face, dossier
de destination ;
- `internal/loki/parity_models_test.go` → `tests/parity_models/models.json` →
`tests/models.rs` : quantification déduite du nom, découpage
d'`EXTRA_ARGS`, jeton masqué, `gated`, classement d'un dépôt, choix du
projecteur, verdicts mémoire, puis un scénario sur disque de 20 appels
(liste, dossiers, suppressions avec et sans `force`, modèle en service,
tranches, chemins refusés) rejoué à l'identique, fichiers restants et
dossiers enregistrés compris.
Deux mutations (`replacen` → `replace` sur `/blob/`, `--mmproj` ignoré)
font échouer les rejeux.
De bout en bout :
- téléchargements contre un faux Hugging Face : trois connexions sur des
plages disjointes, source sans plages, tranches dont une présente, dépôt
verrouillé, panne pendant la deuxième tranche, annulation et 409 ;
- catalogue contre un faux Hugging Face : recherche, cache, taille LFS,
dépôt verrouillé ou absent, jeton refusé ou accepté ;
- toutes les routes par la façade (clé de pilotage, remise du Go, base du Go
écrite), et laissées au Go sans la variable.
## Reste
- Essai réel sur une installation avant d'en faire le défaut.
@@ -0,0 +1,96 @@
# Lot 9 — dictée vocale tenue par la façade
## Contexte
La dictée vocale vivait dans le Go :
- `dictate_config.go` : réglages ;
- `dictate_models.go` : catalogue Parakeet, téléchargement et extraction ;
- `dictate_server.go` : supervision de `sherpa-onnx-offline-websocket-server`
et protocole WebSocket ;
- `web_transcribe.go` : routes.
Lot 9 : la façade la tient quand `LOKI_DICTATE=rust` (expérimental ; sans la
variable rien ne change).
## Ce que sert la façade (`rust/loki-rs/src/dictate.rs`)
| Route | Rôle |
|---|---|
| `POST /api/transcribe` | un WAV (≥ 1000 octets, ≤ 32 Mio) entre, le texte sort ; modèle absent : 503 `{downloading, pct}` et téléchargement lancé (relancé après un échec, qui est dit) |
| `GET\|POST /api/dictate/config` | modèle (validé contre le catalogue) et réactivité ; enregistrer arrête le serveur, qui repart au prochain mot avec le nouveau modèle |
| `GET /api/dictate/models` | catalogue trié par taille, présence des quatre fichiers |
| `GET\|POST /api/dictate/models/download` | un téléchargement à la fois, progression sur les octets reçus |
| `GET /api/dictate/state` | serveur allumé (et depuis quand), modèle, présence, téléchargement |
| `POST /internal/facade/v1/dictate/stop` | (le Go, jeton interne) arrêt sans attendre derrière un démarrage |
Même mécanique que le Go :
**Archive `.tar.bz2`**
- Décompressée au fil du téléchargement, dans un dossier `.part` renommé à la fin.
- Entrées absolues ou qui remontent refusées, liens ignorés.
**Serveur de transcription**
- Lancé à la demande sur un port libre de la boucle locale, GPU masqués, sans
le jeton interne dans son environnement.
- Prêt dès que sa socket répond.
- Une mort au démarrage est rapportée avec sa cause et la dernière ligne du
journal ; un processus qui ne répond pas fait échouer le démarrage au bout
de 2 minutes.
- Éteint après dix minutes d'inactivité.
- `shutdown_fast` tue le processus qui démarre au lieu d'attendre le verrou.
**Protocole**
- En-tête de 8 octets (fréquence, nombre d'octets), échantillons float32 en
trames de 10 Kio, une trame texte en retour.
- Annotations (`[musique]`, `(rires)`) écartées.
## Côté Go (`facade_dictate.go`)
- `withFacadeChat` remet les routes du tunnel et de la redistribution E2E à la
façade. Si elle ne répond pas : 502 « dictée indisponible : … ».
- `asrShutdownVite` passe par la façade quand elle tient la dictée : son
serveur n'est pas un enfant de ce process. Cela couvre la libération de la
VRAM et l'arrêt du moteur principal par l'optimiseur.
## Écarts assumés
- Messages d'erreur de transport (WebSocket, HTTP) et de décompression au
format des crates Rust.
- Fin de connexion sans réponse : « lecture du résultat : connexion fermée ».
## Parité
`internal/loki/parity_dictate_test.go` tire du Go ce journal :
- treize WAV : mono, bloc `LIST` intercalé, stéréo moyenné, flottant 32 bits,
données tronquées, impaires, vides, et les refus ;
- les chemins d'archive ;
- les marqueurs ;
- les réglages par défaut ;
- les arguments du serveur ;
- les adresses des modèles ;
- la présence ;
- treize appels de route.
Le tout est rangé dans `tests/parity_dictate/dictate.json` et rejoué par
`tests/dictate.rs`, les routes par la façade. Une mutation (premier canal au
lieu de la moyenne) fait échouer le rejeu.
`tests/dictate.rs` couvre aussi :
- le protocole contre un faux serveur WebSocket (en-tête, découpage
10240 + 10240 + 3528, marqueurs, réponse illisible, serveur injoignable) ;
- l'extraction (lien ignoré, entrée qui remonte refusée sans rien laisser,
archive corrompue) ;
- le téléchargement depuis un faux serveur (100 %, HTTP 404, modèle inconnu,
un seul à la fois) ;
- la supervision d'un faux sherpa-onnx (script Python qui parle le
protocole) :
- transcription de bout en bout par la façade ;
- même serveur réutilisé ;
- arrêt demandé par le Go, et refusé sans le jeton ;
- réglage changé ;
- les démarrages ratés : sortie en code 3 avec sa dernière ligne, serveur
muet, arrêt « vite » qui tue le démarrage, binaire introuvable, modèle
absent.
@@ -0,0 +1,263 @@
# Lot 28 — Optimiseur tenu par la façade
## Contexte
L'optimiseur sans perte (`backend_tune*.go`) cherche des réglages du moteur
plus rapides **sans rien changer à ce que le modèle calcule** : même
contexte, mêmes slots, mêmes types de cache, même échantillonnage. Il
procède ainsi :
1. Il arrête le vrai moteur.
2. Il lance chaque essai sur une COPIE de la configuration :
- posée dans `LOKI_HOME/tune/run/<essai>/config.env`, jamais
`config.env` ;
- composée par « loki serve » à blanc ;
- puis lancée sur 127.0.0.1 et un port libre.
3. Il mesure chaque essai par le bench complet, à une profondeur fixe.
4. Il garde ce qui gagne au-delà du bruit.
5. Il relance le vrai moteur.
Rien n'est écrit dans un preset sans un geste de l'utilisateur : dans une
copie, ou dans le preset lui-même — moteur redémarré, sondé, et ancienne
version rétablie si la sonde échoue.
Cinq routes :
- `POST /api/tune` lance (202) ;
- `GET /api/tune/status` dit la phase, les essais, l'ETA, puis le résultat ;
- `POST /api/tune/cancel` annule ;
- `POST /api/tune/apply` écrit le résultat, sur un clic (`copy` ou
`preset`) ;
- `GET /api/tune/last` rend le dernier résultat d'un preset, et si le preset
ou le moteur ont changé depuis.
Le moteur, pendant une optimisation, appartient aux essais. Le verrou est un
**fichier** de `LOKI_HOME` (`tune.lock`), créé de façon exclusive, que tout
le monde lit :
- le Go, dans tous ses refus (`tuneGuard`) ;
- « loki tune » en ligne de commande ;
- désormais la façade.
## Le lot
Avec `LOKI_TUNE=rust` (expérimental, Unix seulement), la façade
(`rust/loki-rs/src/tune`) sert les cinq routes et déroule l'optimisation :
- `tune/core.rs` : la partie pure, portée à l'octet près :
- découpe et réécriture d'`EXTRA_ARGS` ;
- réglages par clé ou par drapeau ;
- essais de chaque étape selon la machine et les cases cochées ;
- garde-fous : cœur de la ligne, dénature, clés permises ;
- diff et réécriture du preset ;
- journal d'un essai ;
- score et départage ;
- `tune/lock.rs` : le verrou, **au format du Go** :
- contenu du fichier, clé de prise, binaires comparés par leur nom ;
- identité d'un processus comme `procIdentity` l'écrit : champ 22 de
`/proc/<pid>/stat`, préfixé de l'identifiant du démarrage ; `ps` ailleurs ;
- écriture d'un bloc ;
- rendu neutre avant retrait ;
- ménage par déplacement-relecture ;
- `tune/job.rs` : les routes, le job, les essais et l'application.
**La reprise reste au Go.** Sa reprise (`tuneRecoverWatch`, toutes les
30 s) lit le verrou de la façade et la reconnaît vivante, par la même
identité. Si la façade meurt en plein essai, le Go écarte son verrou comme
celui d'un « loki tune » tué :
- l'essai orphelin est arrêté (identité complète vérifiée) ;
- une application interrompue est défaite ;
- le moteur est relancé s'il tournait.
La façade fait le même ménage quand elle trouve un verrou périmé en prenant
le sien.
**Les essais restent des « loki serve » du Go**, lancés par la façade avec
`LOKI_TUNE_TRIAL` (et `LOKI_TUNE_DRYRUN` à blanc), dans leur propre groupe de
processus. Le groupe est arrêté en sortie, quoi qu'il arrive. L'essai est
noté dans le verrou avant de charger quoi que ce soit.
Le verrou de génération reste celui de la conversation du Go. La façade le
prend par le **bail du moteur**, avec le motif `tune` :
- le Go note alors `benching` et donne aux autres le refus de l'optimiseur ;
- changer de discussion ne rompt pas le bail ;
- un stop du chat, un Reset ou un bail perdu annulent l'optimisation.
Le Go prête à la façade ce qu'il est seul à voir
(`/internal/v1/tune/context`) :
- **le refus du processus web** : un benchmark (le sien ou celui de la
façade), une génération, des jobs d'arrière-plan du mode code ;
- **l'état du vrai moteur** (`engineNeedsStop`) ;
- **la télémétrie**, d'où viennent les poids d'un tour type.
Il prête aussi le démarrage et l'arrêt du moteur **sans le refus de
l'optimiseur** (`/internal/v1/service/action`, `os`), puisque c'est la
façade qui tient le verrou. Si la façade pilote elle-même le moteur
(`LOKI_ENGINE=rust`), elle passe par son superviseur.
Le preset est réécrit et appliqué par les fonctions des presets
(`save_applying`, mode `apply` : la configuration, sans redémarrage). Le
redémarrage, la sonde et le retour arrière sont ceux de l'optimiseur.
La ligne de commande (`loki tune`) reste au Go.
Hors Unix, la façade ne tient pas l'optimiseur. Elle passe alors
`LOKI_TUNE` vide au Go, qui le garde et ne relaie rien.
## Parité
`internal/loki/parity_tune_test.go` tire du Go un journal
(`tests/parity_tune/core.json`) rejoué par `tests/tune_core.rs`. Il couvre :
- toute la partie pure ;
- le **verrou** : contenu du fichier octet pour octet, clé de prise,
comparaison des binaires ;
- le **résultat** tel qu'enregistré et montré (listes vides écrites `null`
comme une tranche nil du Go) ;
- la vérification **après application** (la configuration est-elle l'essai
retenu ?).
`tests/tune.rs` déroule de vraies optimisations, essais compris : le binaire
de test joue « loki serve ».
- À blanc, il écrit la ligne de commande de l'essai.
- Pour de vrai, il sert un faux llama-server dont le prefill est d'autant
plus rapide que le micro-lot est petit.
Les tests couvrent :
- **une optimisation entière** :
- bail « tune » et verrou au nom de la façade ;
- vrai moteur arrêté puis relancé sans le refus de l'optimiseur, dictée
coupée ;
- quatre essais composés, lancés, mesurés, l'un retenu ;
- copies de configuration sur une adresse privée ;
- résultat enregistré par preset et rendu par `/api/tune/last` ;
- **l'application** :
- dans une copie (rien d'autre ne bouge) ;
- dans le preset (réécrit, appliqué, moteur redémarré et sondé) ;
- refusée sur un preset changé depuis la mesure ;
- **une sonde qui échoue** (contexte perdu) : preset et configuration
rétablis, moteur redémarré dessus, sauvegarde faite avant d'écrire ;
- **les essais refusés** : à la composition, au chargement (mémoire
insuffisante) ; la référence qui ne se compose pas ; la relance qui
échoue, dite dans l'erreur ;
- **l'annulation**, par la route et par le stop du chat ;
- **les refus d'entrée**, avec les messages du Go :
- preset externe, Strata, aucun preset actif ;
- jobs du mode code, moteur occupé, génération en cours ;
- optimisation d'un autre processus (et `elsewhere` dans le statut) ;
- **un verrou périmé** :
- l'essai orphelin arrêté seulement si son identité concorde ;
- une application interrompue défaite ;
- les routes sans la façade.
Les tests du verrou du Go sont portés (`tune/tests.rs`) :
- prise, refus, essai noté, rendu ;
- propriétaire mort ;
- verrou à notre PID qui n'est pas l'un des nôtres ;
- illisible neuf ou vieux ;
- ménage jamais sur un verrou vivant ;
- binaire remplacé ;
- identité d'un processus ;
- arrêt du groupe.
Côté Go, `TestFacadeTune` vérifie :
- les cinq routes partent à la façade, et elles seules ;
- le bail « tune » : refus, bascule de discussion, libération ;
- le contexte : au repos, pendant l'optimisation, pendant un bench ;
- le démarrage et l'arrêt sans le refus de l'optimiseur.
## Mutations
105 mutations du port Rust (verrou et job). Le premier passage en a tué 39.
Les survivantes l'ont été ensuite par les tests ajoutés :
- `tune/tests.rs` :
- un verrou sans PID ;
- l'identité d'un processus (démarrage noté, binaire admis) ;
- l'application planifiée : clé sur option refusée sans l'option, valeur
vide qui retire la clé ;
- `tune/job.rs` : l'ETA du suivi, l'arrondi à la minute, le numéro de build ;
- `tests/tune.rs` :
- **une optimisation entière** :
- l'essai noté dans le verrou pendant la mesure ;
- l'application refusée pendant la mesure ;
- la progression, les confirmations, le gain ;
- aucun changement de moteur signalé quand son build est inconnu ;
- la cible inconnue ;
- **l'application gardée** :
- preset qui n'est plus celui en service, API externe ;
- clé changée ailleurs pendant l'application (défaite) ;
- sauvegarde notée dans le verrou avant d'écrire ;
- **les essais** :
- écartés (ligne dénaturée, doublon, contexte perdu) ;
- muets (délai de chargement) ;
- **la mesure** :
- VRAM mesurée par un faux nvidia-smi ;
- budget atteint, profondeur commune ;
- notes et poids tirés de la télémétrie ;
- écarts de mesure (sous le seuil, nul, decode plus lent) ;
- **le moteur** :
- `/props` sans `default_generation_settings` ;
- SIDE_SLOT sur un seul slot ;
- threads comptés sur les cœurs de la sonde ;
- piloté par la façade (LOKI_ENGINE=rust) ;
- **les refus** :
- un second lancement pendant la prise du bail ;
- une application refusée sans journal ;
- hors phase d'application, un verrou périmé n'est pas défait.
Une mutation du premier passage ne changeait rien (`plan_apply(…).and(Ok(()))`
garde l'erreur) : elle est remplacée par une qui retire vraiment le contrôle,
et tuée.
Restent vingt équivalentes, ou hors de portée d'un test :
- **le verrou** :
- le fichier retiré quand son écriture échoue après sa création (disque
plein), que les tests ne provoquent pas ;
- deux re-vérifications du ménage, contre un propriétaire qui revient
entre deux lectures : courses seulement ;
- la réécriture neutre (essai, moteur actif, sauvegarde effacés) avant le
retrait du verrou : sous Unix le retrait suit aussitôt ; elle ne compte
que si le retrait échoue ;
- **le suivi** :
- le résultat montré hors mesure : il est effacé au départ d'une mesure ;
- l'annulation n'est posée que pendant une mesure ;
- la borne de 6 h du budget, la durée d'un doublon, les étapes restantes :
l'estimation seulement ;
- l'annulation vérifiée entre deux essais et à la fin : l'essai en cours
l'a déjà dite (course) ;
- **la mesure** :
- VRAM nulle et nvidia-smi absent vont ensemble ;
- les candidats générés passent toujours `cfg_check` (garde contre une
génération fautive) ;
- la fin d'un essai constatée avant d'attendre : un réveil perdu, quand
l'essai meurt entre deux attentes (course) ;
- l'essai effacé du verrou après son arrêt : le suivant le remplace, ou le
verrou est rendu ; laissé, un Loki redémarré vérifie son identité avant
tout arrêt ;
- **l'application** :
- une valeur vide gardée dans la configuration gagnante : `cfg_check` et la
réécriture la traitent en clé absente ;
- la configuration active comparée à celle de la mesure : les empreintes
du preset et de la configuration sont prises ensemble, sur le preset dont
l'empreinte est celle de la configuration ; les deux contrôles qui
précèdent (preset inchangé, preset en service) l'impliquent ;
- le preset « non appliqué » : la configuration a changé entre le contrôle
et l'écriture (course) ; le contrôle suivant défait aussi, avec un autre
message.
## Écarts assumés
- Une sortie à blanc qui échoue est lue dans un fichier où vont les deux
sorties (comme `CombinedOutput`), mais une erreur de lecture de
`launch.json` ou d'une requête garde le texte de Rust.
- Un niveau de raisonnement appris pendant les essais n'est pas transmis au
chat du Go quand le Go tient la conversation.
@@ -0,0 +1,82 @@
# Lot 29 — Télémétrie des complétions de la façade
## Contexte
Le Go range chaque complétion dans un anneau en mémoire (`perf_log.go`).
Pour chacune, il note :
- combien du prompt venait du cache, combien a été recalculé ;
- à quelle vitesse ;
- ce que le décodage spéculatif a gardé.
Il expose des agrégats sur `/api/perf/summary`. Il calcule aussi la **perte
de cache** d'une complétion : ce que le cache aurait dû reprendre de la
précédente complétion de la même discussion, et n'a pas repris. Il nomme ce
qui s'est intercalé entre les deux, et signale la perte à l'interface
au-delà d'un seuil tiré du preset.
Quand la façade tient la conversation (`LOKI_CHAT=rust`), ses complétions
n'apparaissaient nulle part. Le résumé des performances ignorait les tours ;
l'optimiseur tirait ses poids d'une télémétrie vide ; l'interface ne
recevait jamais la perte de cache.
## Le lot
Les complétions de la façade rejoignent l'anneau du Go, par un seul point
d'entrée, `/internal/v1/turn/perf`. Il est arrivé sur `main` en même temps que
ce lot (« Conversation tenue par Rust : télémétrie et requêtes en vol comme
le Go »), et la fusion n'en garde qu'un.
- `TurnHost::perf_record` range une complétion. La boucle du tour l'appelle :
- après chaque complétion lue, complète ou non, numérotée dans le tour
(`perfIter`) ;
- avec les empreintes seulement si elle est complète (`perf_prefix`,
FNV-1a cumulé des messages envoyés) ;
- pour une complétion coupée par un stop, rangée incomplète.
La dernière statistique d'une complétion complète porte ce que le Go en
dit : la nature, la perte de cache, ce qui s'est intercalé et l'alerte.
- La nature, posée par l'hôte :
- `main` pour un tour et pour les corrections du builder du mode Code ;
- `verify` pour la passe de vérification isolée ;
- `compact` pour un résumé de compaction, rangé dès que sa réponse se lit,
sans empreintes.
- `chat/perf.rs` porte ce que la façade relève elle-même : la réponse entière
d'un résumé (`perfRecFromWire`, sans échec possible, avec les mêmes
tolérances que `perfNum`), traduite en statistiques d'une complétion.
- Le Go range le relevé comme les siens (`perfRecFromStats`) : numéro,
instant, perte de cache, ce qui s'est intercalé. Il calcule l'alerte au
seuil du preset actif (`perfLostAlertAt`), jamais pour un preset externe.
- La sonde du gabarit n'en part pas : la façade la tient (lot 23) et la lance
elle-même après une complétion complète de forme normale. Le Go lui demande
son verdict (`facadeTplCaps`).
`/api/perf/summary` reste au Go, qui tient l'anneau.
## Tests
- `chat/perf.rs` :
- relevé d'une réponse entière : nombres en chaîne, valeurs hors bornes,
octet parasite, cache d'OpenAI, timings d'un autre type ;
- empreintes cumulées, FNV-1a de référence.
- La boucle du tour :
- deux complétions numérotées, totaux, empreintes qui se prolongent ;
- dernière statistique avec la nature et la perte vues par le Go ;
- complétion coupée rangée incomplète, sans statistique finale.
- Le vrai hôte (`tests/lot6.rs`) :
- complétions d'un tour rangées par le Go, rattachées à la discussion ;
- résumés de compaction (rangés si la réponse se lit, pas sinon).
- Le Go (`web_internal_turnperf_test.go`) :
- complétion rangée dans l'anneau ;
- perte et ce qui s'est intercalé, alerte ;
- preset externe sans alerte, complétion incomplète sans empreintes ;
- nature d'un résumé gardée, un tour par défaut ;
- corps illisible, GET refusé.
## Écarts assumés
- Les empreintes sont calculées sur le JSON des messages tel que Rust
l'écrit (sans l'échappement HTML du Go). Elles ne se comparent qu'entre
complétions de la même discussion et de la même nature, toutes produites
par la façade.
- Une complétion coupée par un stop est rangée sans ses compteurs partiels.
@@ -0,0 +1,54 @@
# Lot 30 — Motifs de relance personnalisés tenus par la façade
## Contexte
Quand le modèle écrit un appel d'outil en toutes lettres au lieu de
l'émettre par le protocole, Loki le reconnaît et relance le tour avec une
consigne corrective. Les motifs embarqués couvrent les formats connus. Un
expert peut les remplacer par la clé d'état `retry_patterns` :
- c'est un tableau JSON de regex du Go, chacune compilée en `(?mi)` ;
- une regex illisible est sautée ;
- une surcharge illisible ou vide laisse les motifs embarqués.
Jusqu'ici, une surcharge posée gardait la conversation au Go
(`facadeChatBlocker`), même avec `LOKI_CHAT=rust`.
## Le lot
La façade lit la surcharge au démarrage de sa conversation
(`chat::generate::retry_patterns`), comme le Go la compile une fois par
processus. Le Go ne garde plus la conversation pour cette clé.
Les regex sont celles du Go (paquet `regexp`), pas celles de `regex`.
`chat::stream::go_regex` les réécrit pour garder le sens du Go :
- les classes Perl et les limites de mot du Go sont ASCII ; celles de
`regex` sont Unicode :
- hors d'une classe : `\w`, `\W`, `\d`, `\D`, `\s`, `\S` deviennent des
classes ASCII ;
- dans une classe : leur équivalent dans la classe ;
- `\b`, `\B` deviennent `(?-u:\b)`, `(?-u:\B)` ;
- `\Q…\E` (texte littéral) devient le texte échappé ; le Go le refuse dans
une classe, la traduction aussi ;
- dans une classe, `[`, `&&`, `~~` sont littéraux pour le Go, et `--` est un
intervalle : ils sont échappés ;
- l'octal du Go (`\101`) est permis (`octal(true)`). Une référence arrière
(`\1`), que le Go refuse, est refusée.
## Parité
`internal/loki/parity_retry_test.go` compile côté Go une soixantaine de
regex (dont celles qu'il refuse). Il note leur première correspondance sur
onze textes : accents, chiffres non ASCII, espaces insécables, crochets,
majuscules accentuées. Le journal (`tests/parity_retry/patterns.json`) est
rejoué par `tests/retry.rs` : même compilation, même correspondance, même
position.
Autres tests :
- la lecture de la surcharge : absente, `null`, vide, mal typée, suivie de
texte ;
- les regex sautées, les correspondances ;
- la surcharge lue au démarrage de la conversation de la façade ;
- côté Go, la clé ne garde plus la conversation.
@@ -0,0 +1,88 @@
# Lot 31 — Slots du moteur pour le tour tenu par la façade
## Contexte
Trois clés opt-in du moteur local gardaient la conversation au Go
(`facadeChatBlocker`), faute d'être honorées par la façade :
- `SIDE_SLOT` (`llm_sideslot.go`) : un second slot pour les travaux annexes.
Chaque requête de Loki porte son `id_slot` : 0 pour le fil de la
discussion (étape d'un tour, résumé en continuation), 1 pour le reste
(vérification, résumé sur transcription). Un cache KV à court de place en
plein calcul est rejoué tel quel, sans compaction : c'est la faute du
voisin, pas un prompt trop long.
- `SLOT_PERSIST` (`llm_slotpersist.go`) : l'état du slot de la discussion est
gardé sur disque à la bascule de preset, et rechargé avant la première
requête de la discussion au retour.
Et l'isolation des travaux annexes (`engineSideJob`, `CACHE_ISOLATE`) : après
la passe de vérification du mode Code, le Go efface le slot local s'il a
servi. La façade la sautait (écart du lot 7), le Go ne voyant pas passer ses
requêtes.
Depuis la fusion de `main`, le Go voit passer chaque requête de la façade au
moteur local (tickets « en vol », `/internal/v1/oai/begin`, `served`, `end`).
La machinerie peut donc rester au Go ; il suffit que la façade la consulte.
## Le lot
### Go
- `/internal/v1/turn/slot` (`facade_slots.go`) :
- entrée : `{"conv":"…","main":true}` ;
- sortie : `{"slot":0|1|-1}`, la valeur d'`engineSlotFor` ;
- avec `main` et `conv`, recharge d'abord l'état gardé de la discussion
(`slotPersistRestore`), comme la boucle Go avant une requête d'un tour.
- La sauvegarde à la bascule ne change pas. Le slot porte la discussion dès
que le moteur accepte une étape de son tour : `/internal/v1/oai/served`
appelle `engineMarkMain`.
- `SIDE_SLOT` et `SLOT_PERSIST` ne gardent plus la conversation.
### Rust
- `TurnHost::engine_slot`, appelé avant chaque requête. La boucle pose
l'`id_slot` rendu dans le corps.
- Le rejeu « cache KV plein » (`sideSlotPoolError`) n'a lieu qu'avec un slot
en service et un moteur local :
- deux fois au plus, après l'attente des reprises réseau ;
- puis l'erreur telle quelle ;
- ni compaction ni réduction.
- `GoTurnHost` :
- moteur externe : aucun slot ;
- étape d'un tour : `main` avec la discussion (donc le rechargement
`SLOT_PERSIST`) ;
- vérification : le second slot, et le ticket « en vol » ouvert sur lui
(`side`).
- `EngineSummarizer` : un résumé est désormais compté en vol, comme
`postSummary`. Il part sur le second slot s'il est fait sur la
transcription, sur le slot 0 s'il est fait en continuation.
- La passe de vérification est encadrée par `/internal/v1/engine/sidejob`.
À la fin, le Go efface le slot si le moteur a servi.
## Tests
- Go (`facade_slots_test.go`) :
- sans la clé, aucun slot et `/props` jamais lu ;
- fil de la discussion sur le 0, le reste sur le 1 ;
- second slot hors service ;
- rechargement une seule fois, pour la bonne discussion et une étape de tour
seulement ;
- une étape de la façade acceptée par le moteur suffit à la sauvegarde ;
- corps illisible, GET refusé.
- La boucle du tour :
- `id_slot` dans chaque requête, slot demandé avant chaque envoi ;
- cache KV plein : rejoué deux fois sans compaction, puis l'erreur ;
- sans slot, ou moteur externe : un débordement comme un autre ;
- motif `sideSlotPoolError` repris des cas du Go.
- Le vrai hôte (`tests/lot6.rs`) :
- contrat du mode Code : le tour sur le 0, la vérification sur le 1 ;
- tickets ouverts sur le second slot, travail annexe ouvert puis rendu ;
- résumés : transcription sur le 1, continuation sur le 0, tickets rendus.
## Écarts assumés
- Le rejeu « cache KV plein » se fie au slot demandé pour cette requête. Le Go
relit `sideSlotLive` au moment de l'erreur ; les deux lectures partagent
le même cache de dix secondes.
- Le slot est demandé avant de composer la requête, et non juste avant de
l'envoyer : rien ne se passe entre les deux.
@@ -0,0 +1,97 @@
# Lot 32 — Images gardées (KEEP_TURN_IMAGES) dans le tour de la façade
## Contexte
Sans la clé, une image montrée par un outil (capture, `see_image`) est
éphémère. Le modèle la voit pendant le tour, puis elle disparaît de
l'historique. Le tour suivant renvoie donc un fil où elle manque : le
préfixe en cache diverge à elle, et toute la boucle d'outils qui suivait est
recalculée.
`KEEP_TURN_IMAGES` (`chat_keep_images.go`) garde l'image telle qu'envoyée,
sous conditions :
- vision réellement active ;
- image rangée par référence et relue à l'octet près ;
- coût **mesuré** par le moteur, jamais estimé ;
- toutes les images gardées tiennent dans 10 % de la fenêtre.
Les images gardées partent avant tout résumé. La clé gardait la conversation
au Go.
## Le lot
### Go (`facade_keepimg.go`)
- `/internal/v1/turn/keepimg` rend deux choses :
- si la vision est réellement active (`visionEnabled`, `engineSeesImages`) ;
- le budget des images gardées (`keepImgBudget`).
- `/internal/v1/turn/keepimg/store` range une image relayée par référence
(`refRelayImage`) :
- fichier chiffré si la mémoire l'est ;
- date rafraîchie ;
- relu à l'octet près.
Il rend le message qui la porte ainsi et son poids en octets, ou un refus.
- `KEEP_TURN_IMAGES` ne garde plus la conversation.
### Rust
- `chat/keepimg.rs` porte l'état du tour (`keepImages`) :
- `relay` : image gardée à l'essai, dans `messages` et `extra` ;
- `sending` ;
- `observe` : mesure de la requête qui l'a montrée la première, partage au
prorata des octets, seuil de 16 jetons, budget ;
- `rewritten` ;
- `publishable` ;
- `finish` : une image à l'essai jamais mesurée redevient éphémère.
Une image refusée arrête le tour pour les suivantes.
- La boucle du tour :
- `TurnHost::keep_images` donne le budget, mais seulement pour le fil
principal d'une discussion, avec la clé et la vision active ;
- `TurnHost::keep_image` range l'image par le Go ;
- l'historique réécrit en cours de tour est publié sans les relais non
gardés.
- Les images gardées partent d'abord, avant tout résumé :
- au filet réactif (prompt trop long) : requête rejouée sans elles ;
- en cours de tour, au seuil : la compaction ne suit que si elle reste
nécessaire ;
- sur une complétion coupée par une fenêtre pleine : rejouée sans résumé
si leur retrait suffit.
- Début et fin de tour (`keptImagesTurnStart`) : les images gardées sont
retirées si le contexte le demande, ou si elles ne doivent plus partir
(clé retirée, vision absente).
La compaction et la réduction forcée retiraient déjà les images gardées
d'abord (`chat/compact.rs`).
## Tests
- `chat/keepimg` :
- clé ;
- gardée au coût mesuré ;
- budget ;
- sans mesure (pas d'usage, complétion coupée, pas de base) ;
- requête relancée ;
- rangement refusé ;
- partage entre deux images ;
- publication, réécriture, fin de tour, budget plein avant rangement.
- La boucle du tour :
- requête identique avec et sans la clé, image gardée par référence au
coût mesuré ;
- refus : budget, pas d'usage, rangement refusé ;
- image à l'essai en fin de tour ;
- retrait en cours de tour, au filet réactif et sur une fenêtre pleine, à
chaque fois sans résumé.
- Le vrai hôte (`tests/lot6.rs`) :
- image rangée par le Go, vue en data-URL par le moteur, gardée avec son
coût ;
- clé retirée : l'image part au début du tour suivant, légende et marque
gardées.
- Le Go (`facade_keepimg_test.go`) :
- état sans projecteur puis vu par le moteur, budget ;
- rangement relu à l'octet près ;
- refus : texte seul, URL distante, format inconnu, base64 illisible, deux
images ;
- corps illisible, GET refusé.
@@ -0,0 +1,112 @@
# Lot 33 — Préchauffage (PREWARM) tenu par la façade
## Contexte
Certains recalculs sont inévitables :
- le prompt réécrit par une compaction ;
- le dernier message assistant rendu autrement au tour suivant ;
- la discussion reprise après une tâche qui a pris le slot.
Sans la clé, ils tombent sur le premier jeton du message suivant.
`PREWARM` (`chat_prewarm.go`) envoie au moteur local, dès qu'il est libre, la
requête du prochain tour telle qu'elle partira, suivie d'une sentinelle « . »
et avec `max_tokens` 1. Le moteur calcule tout le préfixe ; le vrai tour ne
calcule plus que son propre message.
Le préchauffage ne passe jamais devant un vrai travail :
- toute requête de Loki l'annule, sauf un tour qui le prolonge exactement ;
- un seul est en vol à la fois ;
- aucune erreur ne remonte.
La clé gardait la conversation au Go.
## Le lot
La façade tient l'historique : c'est elle qui assemble et envoie. Le Go
garde le compteur des requêtes en vol (`engineGate`) et ce qui annule.
### Go (`facade_prewarm.go`)
- `/internal/v1/prewarm/check` dit si le préchauffage peut partir :
- clé posée, moteur local ;
- ni travail annexe, ni bench, ni optimiseur en cours ;
- loin de minuit ;
- moteur en bonne santé ;
- slot visé : un seul slot, ou celui de la discussion avec SIDE_SLOT.
- `/internal/v1/prewarm/begin` :
- recharge l'état SLOT_PERSIST de la discussion ;
- inscrit le préchauffage comme LA requête en vol (`prewarmBegin`) ;
- son annulation (une autre requête part) est relayée à la façade
(`/internal/facade/v1/chat/prewarm/cancel`).
- `/internal/v1/prewarm/end` :
- rend la place ;
- note ce que le moteur a repris (`noteEnginePrompt`).
- `/internal/v1/oai/begin` accepte `prewarm_keep` : l'étape d'un tour de la
façade qui prolonge le préchauffage ne l'annule pas.
- Après une tâche planifiée, le déclencheur part à la façade
(`/internal/facade/v1/chat/prewarm`).
- PREWARM ne garde plus la conversation.
### Rust (`chat/prewarm.rs`)
- L'état partagé :
- le préchauffage en vol, son inscription, son annulation ;
- les capacités du dernier tour de chaque discussion (64 au plus).
- L'assemblage : les mêmes fonctions que le tour.
- Vue du Go, historique sans blocs `<context_update>`, préambule.
- Écho, messages d'envoi, corps.
- Sentinelle « . » (rôle `builder` en mode Code), non streamé, un seul jeton.
- On s'abstient dans ces cas :
- capacités inconnues ;
- tour en cours ;
- discussion vide ;
- compaction attendue ;
- assemblage inattendu.
- Déclencheurs :
- fin de tour quand rien n'attend dans la file ;
- fin de tâche (relayée par le Go) ;
- changement de discussion ou de projet : le préchauffage en cours est
annulé ; avec `full`, la nouvelle discussion est préparée après 3 s.
- Le tour :
- `TurnHost::prewarm_keeps` compare les empreintes des messages envoyés
et des champs qui façonnent le rendu (`prewarmOptsHash`) ;
- une requête qui ne prolonge pas le préchauffage le coupe tout de suite,
sans attendre l'aller-retour par le Go.
- Télémétrie : le préchauffage est rangé dans l'anneau (`kind=prewarm`).
## Tests
- `chat/prewarm` :
- mode (jamais pour un preset externe) ;
- empreinte des options (l'échantillonnage n'y entre pas, un champ absent
n'est pas un champ vide) ;
- rôle de la sentinelle ;
- prolongement ;
- état partagé (un seul en vol, garde, annulation, fin) ;
- capacités bornées.
- La boucle du tour : le drapeau transmis à l'envoi, jamais vers une API
externe.
- Le vrai hôte (`tests/lot6.rs`) :
- fin de tour : requête préchauffée (sentinelle, un seul jeton, même
préfixe et mêmes outils que le tour), inscrite au Go, pas de ticket de
la façade ;
- le tour suivant la prolonge et la garde (`prewarm_keep`) ;
- fin rendue avec total et cache, télémétrie ;
- refus du Go : rien ne part ;
- annulation relayée : connexion coupée, place rendue.
- Le Go (`facade_prewarm_test.go`) :
- contrôle : clé, slots, minuit, travail annexe, moteur muet ;
- inscription et deuxième préchauffage refusé ;
- gardé par le tour qui le prolonge, annulé et relayé sinon ;
- fin unique ;
- requête déjà en vol ;
- déclencheur après tâche relayé.
## Écarts assumés
- L'annulation d'un préchauffage pas encore inscrit au Go (aller-retour en
cours) annule celui qui part, quel que soit son numéro : il n'y en a
qu'un.
@@ -0,0 +1,92 @@
# Lot 34 — Bloc projet figé (PROJ_SNAPSHOT) dans le tour de la façade
## Contexte
Le contexte du projet part en tête du premier message utilisateur :
- description ;
- index mémoire ;
- trackers ;
- consignes du dépôt ;
- avec la clé, la date et le dossier de travail.
Reconstruit à chaque tour, il change dès qu'une page est créée ou qu'un
tracker reçoit un point. Tout ce qui le suit dans le prompt est alors
recalculé.
`PROJ_SNAPSHOT` (`chat_projsnap.go`) garde une copie figée du bloc par
discussion. Les changements arrivent en `<context_update>` en tête du
nouveau message utilisateur, rangés tels quels dans l'historique. Le bloc
n'est rafraîchi que quand le début du prompt change de toute façon :
compaction, système ou outils, clé du moteur, projet, mode, trop de mises à
jour accumulées.
C'était le dernier réglage qui gardait la conversation au Go.
## Le lot
L'instantané est déjà persisté avec la discussion (`ConvState::proj_snap`),
et la compaction le remet à zéro comme le Go. Il manquait la décision, qui
lit le contexte du projet : elle reste au Go.
### Go (`facade_projsnap.go`)
- `/internal/v1/turn/view`, avec la clé et l'agent :
- reçoit l'instantané de la discussion (`snap`) ;
- rend la tête du tour (prompt du preset, puis le bloc figé, ou le vivant
s'il faut rafraîchir) et le préambule sans date ni dossier
(`envInCtx`) ;
- rend aussi `projsnap` : rafraîchir ou non, la mise à jour à poser,
l'instantané à ranger, la clé et l'empreinte du système du tour.
- `dry` (préchauffage) : la même décision, rien à ranger.
- `/internal/v1/turn/projsnap/refresh` : le bloc vivant et l'instantané qu'il
devient (`projSnapTurn.refresh`).
- Plus aucun réglage ne garde la conversation : `facadeChatBlocker` et la
décision prise une fois par processus disparaissent. Le Go la remet dès
que `LOKI_CHAT=rust`, l'adresse de la façade et le jeton sont là.
### Rust
- `ChatService::assemble_turn`, partagé par le tour et la relance du builder
du mode Code :
- rafraîchi : instantané neuf, blocs `<context_update>` retirés de
l'historique ;
- mise à jour : posée en tête du dernier message utilisateur
(`prependCtxUpdate`), dans la discussion et dans la requête ; l'état
annoncé ne bouge que si elle a trouvé où se poser ;
- sans la clé : la discussion qui porte encore des blocs en est nettoyée,
comme avant.
- `TurnHost::project_refresh` : après une compaction ou une réduction en
plein tour, le bloc vivant remplace le figé (`replaceProjectHead`) et les
mises à jour sont retirées. L'instantané posé devient celui de la
discussion à la fin du tour ou de la passe. Le retrait d'images gardées ne
touche pas au début du prompt : pas de rafraîchissement.
- Le préchauffage demande la vue en `dry`. Il garde les mises à jour de
l'historique tant que le bloc n'est pas rafraîchi, comme le vrai tour.
## Tests
- Go (`facade_projsnap_test.go`) :
- la séquence assemblée par la façade égale celle de `turnView`, outils et
instantané compris ;
- une page ajoutée : bloc gardé, mise à jour égale à celle de `turnView`,
état annoncé suivi ;
- sans changement : rien à poser ;
- `dry` sans rien à ranger ;
- clé changée : rafraîchi, bloc vivant en préchauffage ;
- rafraîchissement en plein tour : bloc vivant, instantané à la clé du
tour.
- Go (`facade_chat_test.go`) : avec toutes les clés opt-in posées, la façade
tient la conversation ; sans adresse ou sans `LOKI_CHAT`, le Go et
pourquoi.
- Rust, `chat/compact` : mise à jour en tête (texte, parties, refus) ; bloc
projet remplacé à sa place (cas du Go).
- Rust, boucle du tour : rafraîchi après une compaction et une réduction ;
pas après un retrait d'images gardées.
- Rust, vrai hôte (`tests/lot6.rs`) :
- instantané rangé ;
- mise à jour posée dans la requête et l'historique (marque retirée à
l'envoi) ;
- rafraîchissement qui retire les mises à jour ;
- compaction en plein tour : bloc vivant envoyé, instantané rangé ;
- préchauffage en `dry`.
@@ -0,0 +1,48 @@
# Lot 35 — Routes de base par le tunnel du relais
## Contexte
La façade sert toujours neuf routes, sans variable (`api.rs`) :
- `/api/ping` ;
- `/api/status` ;
- `/api/config` ;
- `/api/engine/cmdline` ;
- `/api/model/layers` ;
- `/api/paths` ;
- `/api/ram` ;
- `/api/vram` ;
- `/api/service/log`.
Mais le tunnel du relais (accès distant, E2E) sert le mux du Go tel quel.
`withFacadeChat` ne remet à la façade que les routes des lots. Un utilisateur
distant tombait donc encore sur les handlers Go de ces neuf routes : deux
implémentations à garder d'accord.
## Le lot
- Go (`facade_core.go`) : une table des routes de base, remises à la façade
par `withFacadeChat` dès qu'elle tourne (adresse et jeton interne
présents), sans variable. Façade muette : 502 « façade indisponible ».
Sans façade (`LOKI_FACADE=0`), le Go les sert lui-même.
- Rust (`api.rs`) : une requête relayée par le Go (jeton interne) a déjà été
authentifiée par lui. Ni clé ni hôte local ne sont exigés de nouveau,
comme pour les routes des lots ; la version du Go est lue quand même pour
`/api/ping`.
## Tests
- Go (`facade_core_test.go`) :
- les neuf routes relayées avec le jeton ;
- une route du Go gardée ;
- façade muette ;
- sans façade, servies ici.
- Rust (`tests/lot2.rs`) :
- relayée par le Go avec un hôte distant et une clé posée : servie par la
façade, pas renvoyée au Go ;
- sans jeton, ou avec un faux : refusée.
## Reste à faire
L'interface statique et `/v1` passent encore par le Go sur le tunnel. Ses
handlers sont gardés pour `LOKI_FACADE=0`.
@@ -0,0 +1,56 @@
# Lot 36 — Appels d'outils MCP sans détour par le Go
## Contexte
Depuis le lot 20, la façade tient le pool MCP (`LOKI_MCP=rust`) : sessions
stdio et HTTP, registre des outils exposés. Le tour de la façade, lui,
faisait encore exécuter un appel `mcp__*` par le Go
(`/internal/v1/tool/exec`).
Le Go ne l'exécutait pas lui-même : `mcpCall` renvoyait l'appel à la façade
(`/internal/facade/v1/mcp/call`). Chaque appel faisait donc deux
allers-retours locaux pour revenir au même pool.
Pour un outil MCP, le Go n'ajoute rien au résultat (`toolStepExtras`) :
- ni capture ;
- ni aperçu du navigateur ;
- ni image relayée.
Seul le texte revient.
## Le lot
- `ChatShared` porte le pool (`mcp`), le même que celui des routes (créé une
fois dans `main.rs`).
- `GoTurnHost::exec_tool` : un nom `mcp__*`, pool tenu → `Mcp::call`
directement. Le résultat est celui de la route interne :
- texte aplati et coupé ;
- « [l'outil a renvoyé une erreur] » ;
- « [ok] (aucune sortie) » ;
- une reconnexion si la session est morte, puis « [erreur MCP] … ».
- Une reconnexion annonce la version du Go dans `clientInfo`, comme la route
interne. Elle est lue une fois (`/internal/v1/auth`) et retenue.
- Sans pool tenu (`LOKI_MCP` absent), rien ne change : l'appel part au Go,
qui l'exécute avec son propre pool.
Le Go garde la route interne pour les tours qu'il exécute encore :
- sous-agents ;
- tâches IA ;
- discussion rendue au Go.
## Tests
`tests/lot6.rs` (`outil_mcp_par_le_pool_de_la_facade`) fait tourner un faux
serveur MCP stdio. Un tour de la façade appelle :
- un outil qui répond ;
- un outil dont le serveur meurt.
Le test vérifie :
- aucun appel au Go ;
- le résultat de chaque outil tel que le modèle le reçoit ;
- l'erreur après une reconnexion ;
- la version du Go annoncée à cette reconnexion.
@@ -0,0 +1,82 @@
# Lot 37 — Outils task_* de l'agent en Rust
## Contexte
Depuis le lot 26, la façade tient les tâches planifiées (`LOKI_TASKS=rust`) :
- les routes ;
- le planificateur ;
- les tâches « script seul ».
Les quatre outils par lesquels le modèle gère lui-même ses tâches
(`tasks_tools.go`) restaient au Go, appelés par `/internal/v1/tool/exec` :
- `task_list` ;
- `task_create` ;
- `task_update` ;
- `task_delete`.
## Le lot
`src/tasks/tools.rs` porte les quatre outils, mêmes messages au caractère
près :
- **`task_list`** :
- liste du projet actif, aperçu d'une ligne par tâche ;
- compte-rendu coupé à 300 octets, un caractère coupé valant U+FFFD par
octet comme à l'encodage JSON du Go ;
- avec un id, la tâche seule et son compte-rendu complet ;
- une tâche d'un autre projet est « introuvable » ;
- **`task_create`** :
- tâche unique « dans N minutes » (durée tronquée comme
`time.Duration(v)`) ou « à HH:MM » (le lendemain si l'heure est passée,
à la même heure murale) ou « à AAAA-MM-JJ HH:MM » ;
- fréquence validée ;
- script vérifié dans le dossier protégé ;
- tâche rattachée au projet actif ;
- fuseau : celui du navigateur, sinon celui de la première tâche qui en a
un ;
- **`task_update`** : modification partielle ; la prochaine exécution est
recalculée sur une nouvelle fréquence, ou à la reprise d'une tâche qui
n'en a pas ;
- **`task_delete`**.
Un projet disparu vaut le projet par défaut (`taskProjectOf`).
`GoTurnHost::exec_tool` les exécute quand la façade tient les tâches
(`ChatShared.tasks`, la même base que celle des routes). Sinon l'appel part
au Go comme avant.
Côté Go, les outils prennent l'heure des tâches (`tasksNow`) au lieu de
`time.Now`. Rien ne change en service ; le journal de parité peut ainsi la
fixer.
## Parité
`internal/loki/parity_tasks_tools_test.go` tire du Go un journal de 44
appels (`tests/parity_tasks/tools.json`). Chaque étape porte :
- l'heure, le fuseau, le projet actif, les projets connus ;
- les scripts présents ;
- une tâche posée telle quelle en base ;
- les arguments tels que le modèle les envoie ;
- le résultat tel qu'il le reçoit ;
- les tâches en base après coup.
Le journal couvre :
- l'heure du trou du changement d'heure ;
- une date passée ou impossible ;
- des arguments d'un autre type ;
- un script absent, hostile ou dossier ;
- le compte-rendu coupé au milieu d'un caractère ;
- le fuseau d'une tâche existante.
`tests/tasks.rs` (`parite_des_outils`) le rejoue contre le port Rust.
`tests/lot6.rs` (`outil_task_par_la_facade`) fait tourner un tour de la
façade qui crée une tâche. Il vérifie :
- rien ne passe par le Go ;
- la tâche est rangée au fuseau de l'utilisateur, dans le projet actif ;
- le résultat est celui que le modèle reçoit.
@@ -0,0 +1,77 @@
# Lot 38 — L'outil criteria du mode Code dans la façade
## Contexte
En mode Code, le modèle tient les critères d'acceptation de sa tâche avec
l'outil `criteria` :
- les poser ;
- les marquer faits ;
- les lister ;
- les effacer.
La passe de vérification les marque ensuite `passed` ou `failed`.
Depuis le lot 19, la façade sert déjà les critères de l'écran
(`/api/chat/criteria`, `chat/crit:<id>` scellés). La passe de vérification
les relit depuis le lot 7. Mais l'outil lui-même partait au Go
(`/internal/v1/tool/exec`), qui le renvoyait à la façade comme delta
(`critNotify` → `/internal/facade/v1/delta`).
## Le lot
`chat/crittool.rs` porte `toolCriteria`, mêmes messages au caractère près :
- **list** : action vide ou d'un autre type comprise ;
- **add** :
- textes vides ou d'un autre type ignorés ;
- ids repris après le plus grand ;
- vingt critères au plus ;
- **set** :
- id numérique (tronqué) ou en chaîne (« "2" », « "#2" », « +1 ») ;
- statut libre, note rognée ;
- `passed` refusé hors de la passe de vérification ;
- **clear**.
Les règles de rangement et d'affichage :
- une liste vide supprime la clé ; une erreur d'écriture est ignorée, comme
dans le Go ;
- une liste rangée illisible vaut « aucun critère » ;
- la note vide n'est pas écrite (`omitempty`).
`GoTurnHost::exec_tool` l'exécute pour la discussion du tour
(`main_conv`). La passe de vérification (`Role` = verifier) est la seule à
pouvoir marquer `passed`. Chaque changement pousse la liste à l'écran (delta
`criteria` + `ts`) par un nouveau champ `deltas` de l'hôte :
- le journal de la discussion pour un tour ;
- celui de la conversation pour une passe de vérification.
Écart assumé : le Go rangeait les critères sous la discussion **active**
au moment de l'appel (`convEnsureActive`). La façade les range sous celle du
tour. Elles ne diffèrent que si l'utilisateur change de discussion pendant
la génération, et c'est alors la façade qui a raison.
`see_image` reste au Go. Il redresse et réduit l'image (EXIF, taille) avant
de l'encoder, ce qu'un port ne rendrait pas octet pour octet.
## Parité
`internal/loki/parity_criteria_test.go` tire du Go un journal de 22 appels
(`tests/parity_tools/criteria.json`). Chaque étape porte :
- les arguments, builder ou vérification ;
- le résultat ;
- les critères rangés ensuite.
`tests/tools.rs` (`criteria_comme_le_go`) le rejoue contre le port. Il
vérifie aussi :
- le rangement scellé ;
- la liste poussée à l'écran à chaque changement, et seulement alors.
`tests/lot6.rs` (`contrat_verifie_par_la_facade`) fait tourner un vrai
contrat. La passe de vérification marque le critère `passed` dans la
façade : rien ne passe par le Go, le critère est rangé scellé, l'écran
reçoit la liste.
@@ -0,0 +1,92 @@
# Lot 39 — Anneau de télémétrie tenu par la façade
## Contexte
Chaque complétion qui passe par le moteur est rangée dans un anneau
(`perf_log.go`). L'anneau :
- calcule la perte de cache d'une complétion contre la précédente de la même
discussion et de la même nature ;
- nomme ce qui s'est intercalé (sous-agent, client externe…) ;
- signale une perte au-delà du seuil du preset (stats finales) ;
- nourrit `/api/perf/summary` et les poids de l'optimiseur.
Depuis le lot 29, la façade envoyait chaque complétion de ses tours au Go
(`/internal/v1/turn/perf`). C'était un aller-retour par complétion pour un
anneau tenu ailleurs.
## Le lot
**Rust** (`src/perfring.rs`, port de `perf_log.go`) :
- l'anneau : 5000 entrées, 128 discussions suivies, 500 entrées remontées au
plus pour nommer ce qui s'est intercalé ;
- la perte : seulement si la complétion prolonge strictement la précédente
(empreintes cumulées des messages), cache connu, complétion allée au bout ;
- la ligne `[perf]` (LOKI_PERF_LOG) ;
- le seuil d'alerte, dans le même ordre de priorité qu'au lancement :
- `-cms` d'EXTRA_ARGS ;
- `LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT` ;
- `CKPT_MIN_STEP` ;
- 2048 sur un modèle hybride ;
- plus le micro-batch ;
- le résumé de `/api/perf/summary` (par nature, par profondeur, brouillon,
dernier verdict de la sonde de gabarit).
`ChatShared` porte l'anneau. Les tours, les passes de vérification, les
résumés de compaction et le préchauffage de la façade y rangent leurs
complétions directement. Les marques (perte, ce qui s'est intercalé,
alerte) partent sur les stats finales comme avant.
La façade sert :
- `POST /internal/facade/v1/perf/record` : une complétion faite par le Go
(sous-agent, tâche IA, bench, compaction et préchauffage de ses tours,
client externe de `/v1`), rendue telle que rangée ;
- `POST /internal/facade/v1/perf/snapshot` : l'anneau, pour les poids de
l'optimiseur ;
- `GET /api/perf/summary`.
**Go** (`facade_perf.go`) : quand la façade tient la conversation,
`perfRecord` range chez elle, et `perfSnapshot` relit son anneau
(optimiseur, contexte de `/internal/v1/tune/context`). `/api/perf/summary`
lui est remis (`facadeChatRoutes`). Façade injoignable : l'anneau du Go
prend le relais. Sans façade, ou la conversation rendue au Go
(`LOKI_CHAT=go`), rien ne change.
La route `/internal/v1/turn/perf` est retirée : plus personne ne l'appelle.
## Parité
`internal/loki/parity_perf_test.go` tire du Go un journal
(`tests/parity_perf/perf.json`). Quatorze complétions y sont rangées, avec
les empreintes de leurs messages ; elles couvrent :
- prolongement ;
- perte après un sous-agent et un client externe ;
- cache inconnu ;
- divergence ;
- autre discussion ;
- complétion coupée ;
- compaction, bench, vérification.
Pour chaque complétion, le journal garde ce qui est rangé, la ligne
`[perf]` et l'alerte. Il porte aussi le résumé, le résumé vide, et onze
seuils d'alerte. `src/perfring/tests.rs` le rejoue, avec l'anneau borné et
l'heure posée.
`tests/lot6.rs` :
- `cache_perdu_mesure_par_la_facade` : deux tours de la même discussion ;
la perte est mesurée par la façade et signalée dans les stats finales ;
- `anneau_de_la_facade_pour_le_go` : ce que le Go confie (perte après un
client externe), ce qu'il relit, le jeton exigé, `/api/perf/summary`
servi par la façade ;
- les tests du lot 29 lisent désormais l'anneau de la façade.
`internal/loki/facade_perf_test.go` (`TestFacadePerfAnneau`) :
- complétion confiée à la façade ;
- anneau relu ;
- repli sur l'anneau du Go quand la façade est en panne ;
- route remise.
@@ -0,0 +1,87 @@
# Lot 40 — Outils mémoire de l'agent dans la façade
## Contexte
Depuis le lot 13, la façade tient la mémoire quand `LOKI_MEM=rust` :
- pages ;
- index `MEMORY.md` ;
- trackers ;
- mode mémoire.
Les outils par lesquels le modèle s'en sert partaient pourtant au Go
(`/internal/v1/tool/exec`) :
- `mem_search`, `mem_read`, `mem_add`, `mem_edit` ;
- `tracker`.
Deux processus écrivaient donc les mêmes pages et le même index.
## Le lot
`src/memory/tools.rs` porte les outils (`chat_memory.go`, `tracker.go`),
mêmes résultats au caractère près. Il réutilise les lectures et écritures des
routes de la mémoire :
- pages scellées selon le chiffrement ;
- `.bak` de la version précédente ;
- écriture vérifiée ;
- index tenu par le code.
Chaque outil :
- **`mem_search`** : classement par couverture des termes puis par
pertinence (rareté IDF, fréquence amortie, bonus du nom et du titre) ;
extrait coupé à l'octet comme le Go (U+FFFD par octet d'un caractère
coupé) ; limite 8 par défaut, 30 au plus ;
- **`mem_read`** : lignes numérotées, plage, 500 au plus ;
- **`mem_add`** : jamais par-dessus une page existante, index tenu (seed au
nom du projet), diff d'ajout ;
- **`mem_edit`** : `old` unique, édition déjà en place reconnue, diff ;
- **`tracker`** :
- vue d'ensemble ;
- squelette (années, année en cours par mois) ;
- une année par mois ;
- points (60 plus récents au plus) ;
- ajout, modification, suppression d'un point (jamais d'un tracker
entier) ;
- arguments d'un autre type rendus comme `str` du Go.
`tracker_add` prend désormais l'heure en paramètre (`tracker_add_at`).
`ChatShared.memory` porte la même mémoire que les routes. `GoTurnHost::exec_tool`
y exécute les outils quand la façade la tient ; sinon l'appel part au Go
comme avant.
Côté Go, l'heure des trackers (« maintenant », année en cours) passe par
`trackerNow`, que le journal fixe ; rien ne change en service.
## Parité
`internal/loki/parity_mem_tools_test.go` tire du Go un journal de 57 appels
(`tests/parity_mem/outils.json`), en fuseau UTC et à heure fixée. Chaque
étape porte :
- les arguments ;
- le libellé du tour ;
- le résultat, le diff ;
- les fichiers de la mémoire du projet (`.bak` et index compris) ;
- les trackers en base.
Il couvre :
- recherche : couverture, rareté, requête vide, limites, extrait coupé ;
- lecture : plage, au-delà, page absente, nom invalide ;
- ajout : existant, nom invalide, majuscules ;
- modification : rejouée, déjà en place, absente, en double, vide ;
- trackers : chaque vue, dates illisibles, nom en nombre, identifiants
inconnus.
`tests/memory.rs` (`outils_memoire_comme_le_go`) le rejoue contre le port.
`tests/lot6.rs` (`outil_memoire_par_la_facade`) fait tourner un tour de la
façade qui crée une page. Il vérifie :
- rien ne passe par le Go ;
- la page est rangée dans le projet actif, l'index tenu ;
- le résultat est celui que le modèle reçoit.
@@ -0,0 +1,133 @@
# Lot 41 — Compilation de llama.cpp depuis les sources dans la façade
## Contexte
Depuis le lot 22, la façade tient les backends llama.cpp quand
`LOKI_BACKENDS=rust` :
- statut, vérification git ;
- binaires précompilés ;
- bascule fast/opt ;
- backends custom ;
- le job des moteurs.
La compilation depuis les sources restait au Go. Il en rapportait chaque
étape au job de la façade par l'API interne
(`/internal/facade/v1/build-job/*`). Elle couvre trois routes :
- `POST /api/llamacpp/install` `{force}` ;
- `POST /api/llamacpp/update` `{clean}` ;
- `POST /api/llamacpp/install-custom` `{repo, name, ref}`.
## Le lot
`src/backends/build.rs` porte la compilation sous Linux et macOS. Sources Go :
- `backend_build.go` : plan, CMake, étapes suivies ;
- `lcRunInstall`, `lcRunUpdate`, `lcBuildAndSwitch`, `lcAppendLogTail`
(`web_llamacpp.go`) ;
- `installCustomBackend`, `deriveBackendName`, `looksLikeGitURL`
(`backend_llamacpp.go`) ;
- `autoInstallTool`, `ensureCompiler` (`sys_platform_unix.go`).
Le port couvre :
- **le plan complet** (`detectBuildPlan`) :
- drapeaux Release, natif, UI de llama-server coupée ;
- Metal sous macOS ;
- CUDA quand nvcc et un GPU NVIDIA sont là : F16, racine du toolkit et
compilateur explicites hors `/usr`, architectures lues par
`nvidia-smi` ;
- HIP, puis Vulkan ;
- **CMake** :
- `build/` configuré ailleurs (dépôt déplacé) ou `clean` : mis de côté en
`build.old` ;
- nvcc dans le PATH et `CUDACXX` ;
- `configure.log` et `build.log` dans le dépôt ;
- erreurs « configuration CMake échouée » et « compilation échouée » ;
- **les étapes suivies** :
- sorties standard et d'erreur dans un même tube, ordre gardé ;
- git : découpage du `sinkWriter` (barre qui se redessine montrée toutes
les 10 s, reste sans fin de ligne perdu) ;
- CMake : découpage du `bufio.Scanner` (dernier morceau rendu, ligne de
plus de 1 Mio qui arrête la lecture) ;
- octets invalides en U+FFFD un à un ;
- erreurs au format d'`exec` (`exit status N`, `signal: killed`,
programme introuvable) ;
- **les outils** :
- git et cmake, puis cc, c++, make ;
- un manquant est installé au mieux par apt-get, dnf, pacman ou brew
(sudo hors root) ;
- sinon le message du Go ;
- **les jobs** :
- installation : clone, compilation propre, BIN ; dépôt présent : bascule
en mise à jour ; `force` : supprimé puis recloné ;
- mise à jour : fetch, pull au besoin, commits avant et après ;
« déjà à jour » sans recompiler ; service arrêté le temps du build et
relancé même en échec ;
- échec du build : fin des deux journaux (30 lignes) remontée dans le job ;
- backend custom : URL vérifiée, nom tiré du dépôt (propriétaire ajouté à
un fork nommé llama.cpp) ou assaini, clone ou mise à jour, ref, sans
toucher à BIN.
Les routes répondent comme le Go :
- moteur fourni par l'image : 409 et le message du Go ;
- custom sans URL : 400 « URL du dépôt requise » ;
- optimisation en cours : 409 ;
- job déjà lancé : 409 ;
- sinon 200 `{"ok":true}` et le job tourne.
Le service moteur est celui de la façade si elle le pilote, sinon celui du
Go (`/internal/v1/service/action`), comme pour les binaires précompilés.
Côté Go, `facadeBackendsRoutes` gagne les trois routes hors Windows. Sous
Windows, la compilation reste au Go, qui rapporte toujours ses étapes à la
façade :
- générateur Visual Studio ;
- intégration CUDA de MSBuild ;
- `CUDA_PATH`.
Les messages du terminal du Go (outils manquants, dépendance connue absente
après un configure en échec) vont à la sortie d'erreur de la façade.
## Parité
`internal/loki/parity_build_test.go` (Linux) tire du Go un journal de 15
étapes (`tests/parity_backends/build.json`). De faux git, cmake et
compilateur, rangés dans le journal, sont seuls sur le PATH. Chaque étape
porte :
- la vue du job (lignes, phase, erreur, commits, fichiers compilés) ;
- BIN ;
- les dépôts qui ont un `build.old`.
Il couvre :
- mise à jour sans dépôt ;
- installation, puis installation sur un dépôt présent et installation
forcée ;
- déjà à jour, nouveau commit, recompilation propre ;
- build en échec (fin des journaux) ;
- cmake absent, compilateur absent ;
- clone en échec ;
- custom : URL invalide, nouveau fork, nom assaini sur un ref, dossier
existant sur un ref (URL `git@`).
`tests/backends.rs` (`compilation_comme_le_go`) rejoue le journal par la
façade : mêmes faux outils dans le PATH des sondes, POST de la route, job
suivi jusqu'au bout. `compilation_refusee` vérifie les refus des routes.
Les tests unitaires de `build.rs` couvrent :
- la barre de progression (`TestSinkWriterProgression`) ;
- le scanner (fin `\r\n`, dernier morceau, octets invalides, ligne
démesurée) ;
- la racine du toolkit CUDA (`TestCudaToolkitRoot`) ;
- le cache CMake déplacé ;
- noms et URL des backends custom ;
- le plan par machine (CPU, nvcc du PATH, toolkit versionné,
architectures, HIP, Vulkan) ;
- les erreurs des commandes (code de sortie, signal, programme absent,
journal écrit).
@@ -0,0 +1,121 @@
# Lot 42 — Outils web de l'agent dans la façade
## Contexte
La façade tient la conversation par défaut. Les outils web de ses tours
partaient pourtant au Go (`/internal/v1/tool/exec`) :
- `web_search` ;
- `web_images` ;
- `web_open`, `web_read`, `web_grep`.
Le cache des pages (10 min) était donc au Go : `web_open` puis `web_read`
faisaient chacun un aller-retour.
## Le lot
`src/webtools.rs` porte les outils. Sources Go :
- `chat_internet_tools.go` : les quatre outils de lecture et de recherche ;
- `chat_internet.go` : client Crawl4AI, cache, lecture des résultats
DuckDuckGo ;
- `chat_web_images.go` : recherche d'images.
Ce qui est porté :
- **le cache** :
- clé : l'URL, suivie de l'empreinte MD5 des options qui changent la page
(actions, attente, bandeaux), à l'échappement HTML près du Go ;
- `web_read` et `web_grep` lisent l'ouverture la plus récente d'une URL,
toutes options confondues ;
- 10 minutes ;
- **Crawl4AI** :
- corps envoyé au Go près (clés triées, échappement HTML) ;
- clé d'accès en `Bearer` ;
- réponse `{results:[…]}` ou résultat seul, lue comme `json.Unmarshal`
(types faux, `null`, casse des clés) ;
- markdown en chaîne ou `{raw_markdown, fit_markdown}` ;
- erreurs : HTTP (fin du corps), échec du crawl, réponse vide, serveur
injoignable (texte de l'erreur du client Go) ;
- **les lignes** : `\r\n`, blancs de fin, vides et doublons consécutifs
réduits ; plan des titres ;
- **la recherche par Crawl4AI** : titres, liens `uddg` décodés deux fois
comme le Go, entités HTML, extrait tiré des liens qui suivent, doublons et
liens DuckDuckGo écartés, défi anti-bot ;
- **les images** :
- jeton `vqd` de la page, puis `i.js` ;
- mêmes en-têtes ;
- cookies gardés par hôte, comme le jar du client Go ;
- réponse lue comme `json.Unmarshal` (erreurs de syntaxe et de type au
texte du Go) ;
- **`web_grep`** :
- regex du Go (`(?i)`, classes ASCII) ;
- erreurs au texte de `regexp` pour les fautes courantes ;
- **le plafond** de 8000 caractères ;
- **les arguments** : nombres tronqués comme `int(float64)` du Go.
Le moteur intégré lit le HTML avec go-readability, html-to-markdown et le
parseur HTML5 de x/net : rien en Rust ne les reproduit au caractère près.
Ces deux étapes restent au Go, derrière deux appels sans état :
- `POST /internal/v1/web/fetch` `{url}` → `{markdown}` ou `{error}`
(`goFetchMarkdown`) ;
- `POST /internal/v1/web/search` `{query, limit}` → `{results}` ou `{error}`
(`goSearch`).
`ChatShared.web` porte les outils ; `GoTurnHost::exec_tool` les exécute
avant tout le reste. `web_screenshot` reste au Go (navigateur piloté).
Le traducteur des regex du Go (`go_regex`) refuse désormais :
- la répétition d'une répétition (`a**`, `a*??`, `a{2}*`) ;
- un compte au-delà de 1000 ou inversé.
Un `{` qui n'ouvre pas de répétition y devient littéral, comme au Go. Les
motifs `retry_patterns` en profitent.
Côté Go, pour le journal :
- `webNow` : l'horloge du cache ;
- `ddgImagesBase` et `ddgHTMLBase` : les adresses de DuckDuckGo.
## Écart connu
reqwest pose `Accept: */*` sur une requête qui n'en a pas ; le client Go
n'en pose aucun. Seule la page de recherche d'images est concernée, sans
effet sur DuckDuckGo.
## Parité
`internal/loki/parity_web_tools_test.go` tire du Go un journal de 74
appels (`tests/parity_web/outils.json`). Chaque appel porte :
- le résultat ;
- les requêtes reçues par le faux Crawl4AI (méthode, clé, type, corps) ;
- les requêtes reçues par le faux DuckDuckGo (chemin, en-têtes, cookies) ;
- les pages et recherches demandées au moteur intégré.
Il couvre :
- recherche : limites, anti-bot, aucun résultat, panne ;
- lecture : plages, bornes, au-delà, page jamais ouverte ;
- grep : contexte fusionné, plafond, sans résultat, classes ASCII, sept
regex invalides ;
- cache : réouverture, forcée, options, la plus récente, péremption ;
- GitHub ;
- les réponses de Crawl4AI ;
- serveur injoignable, sans serveur, sans clé ;
- moteur intégré : ouverture, texte brut, page absente, recherche ;
- images : doublons, cookie gardé, jeton absent, pannes, réponses
illisibles ou mal typées, `null`.
`tests/webtools.rs` le rejoue contre le port. Le faux Go sert ce que le
moteur intégré a tiré des pages.
D'autres tests complètent le journal :
- `tests/lot6.rs` (`outil_web_par_la_facade`) : un tour de la façade ouvre
une page sans passer par `/tool/exec` ;
- `TestInternalMoteurWebIntegre` : les deux appels internes du Go ;
- tests unitaires : MD5, clés du cache, URL, entités, nombres, erreurs de
regex, réponses de Crawl4AI.
@@ -0,0 +1,82 @@
# Lot 43 — Jobs d'arrière-plan du mode Code dans la façade
## Contexte
En mode Code, le modèle lance un serveur de dev, un watcher ou un build long
avec `bash_bg`. Il en suit la sortie avec `bash_tail`, qui sait aussi
l'arrêter. Les deux outils partaient au Go (`/internal/v1/tool/exec`), qui
gardait les processus. C'étaient les derniers outils shell du tour encore au
Go, `bash` étant porté depuis le lot 7.
## Le lot
`src/tools/jobs.rs` porte `code_jobs.go`.
**`bash_bg`** :
- refus avant tout lancement :
- commande vide ;
- commande dangereuse (même politique que `bash`) ;
- optimisation en cours (`/internal/v1/tune/guard`) ;
- journal `.loki/jobs/<id>.log` dans le dossier de la discussion (le
contexte des outils du Go) ;
- numéros `job1`, `job2`… ;
- shell de la plateforme, sans le jeton interne dans l'environnement ;
- le processus survit au tour ;
- code de sortie du Go : -1 pour un processus tué.
**`bash_tail`** :
- 40 lignes par défaut, 200 au plus ; un nombre est tronqué comme
`int(float64)` ;
- état « en cours » ou « terminé (exit N) » et durée arrondie à la
seconde ;
- sortie vide signalée ;
- fin gardée en caractères ;
- octets invalides en U+FFFD un à un ;
- `kill:true` arrête le job et relit aussitôt, comme le Go : l'état peut
encore dire « en cours ».
Les erreurs d'écriture ont le texte du Go. `paths::go_mkdir_all` reproduit
`os.MkdirAll` : l'erreur nomme l'élément du chemin qui a échoué.
`ChatShared.jobs` (les jobs du processus) sert les tours :
`GoTurnHost::exec_tool` exécute les deux outils. Sans contexte d'outils,
`bash_bg` passe au Go comme avant.
Les jobs d'une discussion supprimée s'arrêtent avec elle
(`GoSessionHooks::dropped`, `stopConvJobs`).
L'optimiseur refuse de mesurer tant qu'un job tourne. Côté Go,
`bgJobsRunning` ajoute ceux de la façade quand elle tient la conversation
(`POST /internal/facade/v1/bgjobs` → `{running}`) ; façade injoignable :
les siens seulement.
## Parité
`internal/loki/parity_jobs_test.go` (Linux) tire du Go un journal de 24
étapes (`tests/parity_jobs/jobs.json`). Chaque étape porte le résultat (pid
et durées masqués) et les journaux présents. Il couvre :
- commande vide, refusée ;
- sortie des deux flux, lignes demandées (nulles, en texte, tronquées) ;
- arrêt d'un job fini ;
- job inconnu, sans id ;
- sortie en erreur sans rien d'écrit ;
- job long, en cours, arrêté (état relu masqué), puis fini à -1 ;
- quarante lignes par défaut, deux cents au plus ;
- sortie démesurée en caractères multi-octets ;
- octets invalides.
`tests/jobs.rs` le rejoue contre le port.
D'autres tests complètent le journal :
- `tests/lot6.rs` (`job_arriere_plan_par_la_facade`) : un tour de la façade
lance un job dans le dossier de la discussion sans passer par le Go ;
supprimer une autre discussion n'y touche pas, la sienne l'arrête ;
- `anneau_de_la_facade_pour_le_go` : le compte des jobs par l'API interne ;
- `TestFacadeJobsArrierePlan` : le Go les ajoute aux siens, façade en panne
comprise ;
- tests unitaires : refus de l'optimiseur, discussion supprimée, dossier
impossible.
@@ -0,0 +1,138 @@
# Lot 44 — Navigateur piloté dans la façade
## Contexte
Le contrôle du navigateur donne à l'agent un Chromium local, piloté par le
protocole DevTools (CDP). L'agent l'utilise ainsi :
- il ouvre une page et lit les éléments interactifs numérotés ;
- il clique, tape, défile par numéro ;
- avec la vision, il voit une capture quadrillée et clique en
coordonnées.
La façade tient la conversation par défaut, mais la session Chrome, les
outils `browser_*` et l'anneau des aperçus montrés à l'utilisateur étaient
au Go (`/internal/v1/tool/exec`).
## Le lot
`src/browser/` porte quatre fichiers Go :
- `computer_cdp.go` : client CDP, lancement, scripts ;
- `computer_use.go` : les neuf outils ;
- `browser_grid.go` : la grille de la capture ;
- `cu_autoshot.go` : les aperçus.
Ce qui est porté :
- **le lancement** :
- binaire imposé, celui de Playwright ou du système ;
- profil temporaire `loki-cu-*`, et ceux de plus d'une heure balayés ;
- mêmes arguments (headless sauf `LOKI_CU_HEADFUL`) ;
- tué avec la façade (`PR_SET_PDEATHSIG`) ;
- port lu dans `DevToolsActivePort`, cible « page » par `/json` ;
- viewport fixe 1280×800 ;
- script qui garde les nouveaux onglets dans l'onglet courant ;
- **la session** :
- une seule, lancée à la demande ;
- vérifiée par un ping avant chaque outil et relancée si elle ne répond
plus ;
- commandes multiplexées par numéro, 30 s au plus ;
- requêtes réseau suivies pour attendre le calme (600 ms, 6 s au plus)
après un chargement ;
- **les scripts** injectés, copiés du Go à l'octet près (le journal
compare leurs empreintes) :
- éléments actionnables et visibles ;
- shadow DOM ;
- nom d'un champ par son libellé, sans révéler un mot de passe ;
- recherche non destructive ;
- liste déroulante native réglée par son option ;
- **les outils**, mêmes textes :
- snapshot dédoublonné ;
- indices de défilement ;
- erreurs de navigation (`errorText`, page `chrome-error://`,
`ERR_ABORTED` bénin) ;
- `back` et `forward` ;
- touches nommées ;
- plafond de 8000 caractères ;
- **la capture** (`browser_screenshot`, vision active) :
- PNG propre rangé dans le dossier de travail
(`capture-AAAAMMJJ-HHMMSS.png`, heure locale) ;
- image du modèle quadrillée tous les 100 px, mêmes pixels que le Go ;
à 1280×800 elle est sous la taille maximale et part telle quelle ;
- relayée au modèle comme `see_image` ;
- **les aperçus** :
- après une action qui change la page, jamais après une erreur ;
- JPEG en mémoire, anneau de huit ;
- servis par `/api/chat/cushot`, que le Go remet désormais à la façade.
`ChatShared.browser` porte le navigateur du processus ;
`GoTurnHost::exec_tool` exécute les outils et prend l'aperçu.
Un seul navigateur : quand la façade tient la conversation, le Go lui passe
ses propres appels (`facade_browser.go`) :
- outils des sous-agents et des tâches IA ;
- aperçus ;
- fermeture (interrupteur coupé, ligne de commande).
Ils passent par `POST /internal/facade/v1/browser`, avec trois opérations :
`tool`, `shot`, `shutdown`. Si la façade ne répond pas, le navigateur du Go
prend le relais. Couper l'interrupteur côté façade (lot 17) ferme les deux.
## Écarts connus
- Encodeurs PNG et JPEG : mêmes pixels, octets différents.
- Une capture au-delà de 1568 px ne serait pas réduite (le viewport fixe
rend le cas impossible).
## Parité
`internal/loki/parity_browser_test.go` (Linux) tire du Go un journal de 42
étapes (`tests/parity_browser/browser.json`) contre un faux Chrome :
- un script annonce le port d'un faux serveur CDP ;
- le faux CDP répond selon le scénario de chaque étape et note chaque
commande.
Chaque étape porte :
- le résultat ;
- les commandes CDP (méthode, paramètres, scripts résumés par leur
empreinte) ;
- pour une capture, ses dimensions et dix pixels de la grille ;
- les captures rangées.
Les arguments de lancement sont relevés aussi. Le journal couvre :
- ouverture, snapshot inchangé, réouverture complète ;
- snapshots vide, `null`, sans valeur, illisible, exception JS ;
- défilements ;
- clics par numéro, introuvable, refusé, en coordonnées ;
- saisies libre, dans un champ, liste déroulante, option absente, champ
introuvable ;
- touches ;
- recherches ;
- navigations en échec, page d'erreur, interruption bénigne, `back`,
`forward`, commande refusée ;
- captures sans vision, quadrillée, refusée ;
- sortie plafonnée.
`tests/browser.rs` le rejoue contre le port, puis vérifie l'aperçu (JPEG
de l'anneau) et la fermeture (profil retiré).
`vrai_navigateur` (avec `LOKI_CU_SMOKE=1`, comme `TestCDPSmoke`) pilote un
vrai Chromium sur une page locale : ouverture, recherche, saisie, liste
déroulante, clic qui navigue, `back`, capture 1280×800, aperçu, navigation
en échec.
D'autres tests complètent :
- `tests/lot6.rs` (`navigateur_par_la_facade`) : un tour de la façade ouvre
une page sans passer par le Go, avec aperçu ; l'API du Go et
`/api/chat/cushot` par une façade complète ;
- `TestFacadeNavigateur` : le Go passe ses outils, captures, aperçus et
fermeture à la façade, et se replie sur son navigateur si elle est en
panne ;
- tests unitaires : outils, nombres au format du Go, arguments, grille,
profils, lancement impossible.
@@ -0,0 +1,116 @@
# Lot 45 — Outils d'image de l'agent dans la façade
## Contexte
Deux outils de l'agent montrent une image au modèle :
- `see_image` charge un fichier image du disque dans la vision du
modèle ;
- `web_screenshot` photographie une page rendue par Playwright, la range
dans le dossier de la discussion et donne à l'agent la ligne markdown
qui la montre à l'utilisateur.
La façade tient la conversation par défaut, mais ces deux outils passaient
encore par le Go (`/internal/v1/tool/exec`), qui renvoyait l'image à
relayer dans `vision_img`.
## Le lot
`src/imagetools.rs` porte trois fichiers Go :
- `chat_vision_tool.go` (`see_image`) ;
- `chat_screenshot.go` (`web_screenshot`) ;
- `prepareImageForModel` (`web_upload_orient.go`).
Ce qui est porté :
- **la préparation de l'image**, la même que pour les pièces jointes :
- orientation EXIF d'une JPEG redressée dans les pixels ;
- grand côté ramené sous 1568 px (mêmes pixels que le Go) ;
- PNG réencodée en PNG, le reste en JPEG qualité 88 ;
- rien à faire, ou image que la bibliothèque standard du Go ne décode
pas (WebP, BMP, PNG cassée) : les octets d'origine ;
- **la sonde du moteur** (`engineSeesImages`) :
- preset externe : sa case « vision » ;
- sinon `GET /props` du moteur local, authentifié par la clé de
complétion, gardé 10 s ;
- appelée en dernier, après toutes les vérifications du fichier ;
- **`see_image`**, mêmes textes :
- vision coupée, poste distant, chemin manquant ;
- format non reconnu, fichier introuvable, dossier ;
- trop lourde (12 Mo au plus) ;
- moteur aveugle, fichier vide ;
- chemin relatif au dossier de travail de l'agent, ou absolu ;
- **`web_screenshot`**, mêmes textes :
- Playwright absent, url manquante ou invalide ;
- internet coupé : seule une adresse locale passe (`localhost`,
`127.0.0.1`, `::1`, `0.0.0.0`, `*.localhost`, casse comprise comme
le Go) ;
- mêmes arguments de Playwright (viewport, page entière, attente,
90 s au plus) ;
- erreur résumée par ses trois dernières lignes non vides ;
- aucune image ou image vide refusée ;
- capture rangée dans `captures/` du dossier de la discussion, nommée
par l'hôte et l'heure locale ;
- ménage : 20 captures et 40 Mo au plus, la nouvelle reste ;
- relayée au modèle (« Voici la capture demandée. ») seulement si la
vision est active et que le moteur voit les images.
`ChatShared.vision` porte la sonde du processus ; `GoTurnHost::exec_tool`
exécute les deux outils :
- `see_image` prend le dossier de travail et le poste visé du contexte
des outils (lot 7) ; contexte indisponible : le Go prend le relais ;
- `web_screenshot` range ses captures dans le dossier de la discussion
principale, sous le dossier de travail de l'agent, et les montre par
`ToolOutcome.image`.
Rien ne change côté Go : ces outils n'ont pas d'état partagé, et le Go
garde les siens pour ses sous-agents et ses tâches IA.
## Écarts connus
- Encodeurs PNG et JPEG : mêmes pixels, octets différents.
## Parité
`internal/loki/parity_image_tools_test.go` (Linux) tire du Go un journal
(`tests/parity_images/images.json`) avec ses fichiers (en base64), un faux
moteur (`/props` authentifié, vision réglée par étape, sondes comptées) et
un faux Playwright (script qui note ses arguments et produit l'image, rien,
un fichier vide ou une erreur).
Chaque étape porte :
- le résultat, avec le texte relayé au modèle ;
- l'image relayée :
- identique à un fichier : son nom ;
- réencodée : son type et ses dimensions ;
- en PNG : cinq pixels ;
- la capture montrée, les arguments de Playwright, le nombre de sondes ;
- le dossier des captures.
Le journal couvre :
- `see_image` :
- vision coupée ;
- chemin vide, pas une image, introuvable, dossier, trop lourde ;
- moteur aveugle, fichier vide ;
- onze images : PNG, JPEG, GIF, WebP, BMP, PNG cassée, JPEG tournée,
grandes images réduites en largeur et en hauteur, sous-dossier,
chemin absolu ;
- `web_screenshot` :
- url manquante ou invalide, internet coupé ;
- capture locale sans vision, capture vue par le modèle ;
- page entière réduite ;
- vision mais moteur aveugle ;
- Playwright en échec, image vide, aucune image ;
- ménage des captures.
`tests/imagetools.rs` le rejoue contre le port. D'autres tests
complètent :
- `tests/lot6.rs` (`outils_image_par_la_facade`) : un tour de la façade
appelle les deux outils sans passer par le Go ;
- tests unitaires : types d'image, noms de capture, adresses locales,
dernières lignes, préparation, ménage, refus avant Playwright.
@@ -0,0 +1,97 @@
# Lot 46 — Outils recall et recall_search dans la façade
## Contexte
La compaction résume les vieux tours d'une discussion. Avant de résumer un
gros bloc (un code, une page, un diff), elle l'archive verbatim sous un id
court (`r7`) que le résumé cite. L'agent a deux outils pour le retrouver :
- `recall(id)` rend le bloc exact ;
- `recall_search(query)` retrouve un bloc et son id par mots-clés, même
sorti du résumé courant.
La façade compacte elle-même et archive par le Go
(`/internal/v1/recall/archive`, lot 6) : l'id vient de la séquence de la
base, que seul le Go tient. Les deux outils passaient encore par
`/internal/v1/tool/exec`.
## Le lot
`src/recall.rs` porte `toolRecall` et `toolRecallSearch`
(`chat_recall.go`) :
- **l'id tolérant** (`recallIDToSeq`) :
- espaces, un préfixe `r` puis un préfixe `R`, espaces encore ;
- des chiffres seulement : ni signe, ni dépassement de 64 bits ;
- un id mal formé ne lit pas l'archive ;
- **les mots-clés** (`recallTokens`) :
- mise en minuscules rune par rune, comme `strings.ToLower` (`İ`
donne `i`, `Σ` donne `σ`) ;
- coupés sur tout ce qui n'est ni `a`–`z`, ni `0`–`9`, ni `à`–`ÿ` ;
- deux runes au moins ;
- **le score** :
- un point par terme présent dans le contenu, deux dans le libellé ;
- à égalité, le plus récent d'abord ;
- 5 résultats par défaut, 20 au plus, limite tronquée comme
`int(float64)` ;
- **les textes**, mêmes que le Go :
- id ou requête manquants ;
- bloc introuvable (id cité par `%q`) ;
- bloc rappelé verbatim ;
- liste des résultats avec un extrait d'une ligne de 160 runes ;
- aucun résultat.
L'archive reste au Go, qui la sert à la façade :
- `GET /internal/v1/recall/block?seq=N` : le bloc (200), ou 404 s'il est
absent ou chiffré sous une mémoire verrouillée ;
- `GET /internal/v1/recall/blocks` : tous les blocs lisibles, par séquence
croissante.
`recallGet` et `recallSearch` du Go passent par les mêmes lectures
(`recallGetSeq`, `recallAll`).
`GoTurnHost::exec_tool` exécute les deux outils avant le repli sur le Go.
Si le Go ne répond pas, l'appel passe par lui.
## Parité
`internal/loki/parity_recall_test.go` archive 35 blocs variés :
- accents et casse Unicode ;
- balises et entités ;
- bloc vide ;
- premières lignes longues ;
- vingt-cinq blocs semblables.
Il tire ensuite du Go un journal de 41 appels
(`tests/parity_recall/recall.json`), avec les blocs tels que l'archive les
sert :
- `recall` :
- id absent, non texte, blanc ;
- variantes de préfixe et d'espaces ;
- zéros de tête ;
- mal formé, négatif, signe plus, zéro, absent, démesuré ;
- guillemets dans l'id ;
- `recall_search` :
- requête absente, non texte, blanche ;
- mots trop courts ;
- casse, accents, `İ`, grec hors des mots ;
- souligné coupé ;
- extraits bornés ;
- limites : une, tronquée, négative, par défaut, au plus, en texte
ignorée ;
- aucun résultat ;
- ponctuation.
`tests/recall.rs` le rejoue : le faux Go sert les mêmes blocs. Il vérifie
aussi le repli quand le Go ne répond pas.
D'autres tests complètent :
- `tests/lot6.rs` (`recall_par_la_facade`) : un tour de la façade rappelle
et cherche sans passer par `/tool/exec` ;
- `TestRecallInterneLecture` : les deux lectures du Go (absent, seq
illisible, méthode refusée, ordre) ;
- tests unitaires : ids, mots-clés, score, extraits, textes.
@@ -0,0 +1,115 @@
# Lot 47 — Outil subagent du mode Code dans la façade
## Contexte
En mode Code, le builder peut déléguer une question bornée à un rôle en
lecture seule :
- `explorer` cartographie le code ;
- `code-reviewer` relit un changement ;
- `planner` découpe un travail et pose les critères d'acceptation.
Le rôle travaille dans son propre contexte : le prompt du rôle, le
briefing machine et la tâche, rien de l'historique. Sa trace n'est ni
rangée ni affichée. Seul son texte final revient au builder, comme
résultat d'outil. Sur un modèle local, à la fenêtre étroite, c'est ce qui
évite de payer dix lectures de fichiers dans le fil principal.
La façade tient le tour du builder et sa passe de vérification, mais
`subagent` passait encore par `/internal/v1/tool/exec` : le Go faisait
tourner un `runChat` à lui.
## Le lot
`src/chat/subagent.rs` porte `toolSubagent` (`code_subagent.go`).
Avant tout appel au moteur, il fait les mêmes refus que le Go, dans le
même ordre, avec les mêmes textes :
- rôle inconnu (`verifier` compris : seule la boucle du contrat le lance) ;
- tâche vide ;
- appel depuis un tour qui n'est ni le fil principal ni le builder
(garde-fou anti-récursion).
Le contexte du rôle vient du Go
(`POST /internal/v1/turn/subagent {role}`), qui rend :
- le prompt du rôle suivi du briefing machine ;
- les outils du rôle ;
- ses capacités (agent, Code, mémoire coupée).
`subagentView` et `subagentFraming` sont partagés avec `toolSubagent`.
Le tour du rôle tourne ici avec `run_turn`, comme la passe de
vérification :
- l'endpoint, le raisonnement, l'écho, les motifs de relance et l'arrêt
sont ceux du tour qui délègue (`SubTurn`) ;
- la température est 0,6 (le TEMP du preset l'emporte) ;
- la tâche porte le cadrage du rôle :
- planner : critères et étapes, sans demander confirmation ;
- autres rôles : seulement les trouvailles, `fichier:ligne` ;
- c'est un travail annexe (`engineSideJob`) : second slot, puis slot
effacé à la fin pour que le builder reparte du cache RAM ;
- la télémétrie le compte à part (`subagent`) ;
- les critères posés par le planner s'affichent comme ceux du fil.
Le rapport se construit ainsi :
- seul le texte écrit après le dernier outil compte ;
- il est rogné de ses blancs, puis préfixé du rôle ;
- au-delà de 6000 runes, il est tronqué (`…[tronqué]`) ;
- vide : « n'a rien répondu » ;
- erreur du tour : « sous-agent <rôle> : <erreur> ».
Le tour du rôle est porté par `SubHost`. Ce `TurnHost` délègue tout au
`GoTurnHost` du rôle, sauf un nouvel appel à `subagent`, toujours refusé.
Les outils de la façade sont dans `GoTurnHost::exec_tool_base` ; seul le
`exec_tool` du tour principal y ajoute `subagent`. Ainsi le type du tour
imbriqué ne se contient pas lui-même.
Le tour du builder et ses relances par la boucle du contrat prêtent leur
`SubTurn`. La vérification aussi, mais son rôle la fait refuser.
## Parité
`internal/loki/parity_subagent_test.go` tire du Go un journal
(`tests/parity_subagent/subagent.json`) contre un faux moteur scripté. Les
vues des trois rôles sont rangées avec lui.
Pour chaque tour délégué, le journal garde chaque requête du rôle :
- système identique à la vue ;
- message de la tâche cadrée ;
- température ;
- outils ;
- nombre de messages.
Il couvre :
- les refus :
- rôle inconnu, `verifier`, rôle absent ;
- tâche vide ou absente ;
- récursion depuis un rôle ou depuis la vérification ;
- les tours :
- réponse directe rognée ;
- rapport après deux outils, narration écartée ;
- texte vide relancé puis remplacé, comme le fait le tour ;
- rien après l'outil ;
- rapport tronqué ;
- cadrage du planner ;
- moteur qui refuse.
`subagent_comme_le_go` (`tests/lot6.rs`) le rejoue :
- les refus par `precheck` ;
- les tours par un tour complet de la façade qui délègue, contre le même
script.
Il vérifie les requêtes du rôle, le rapport rendu au builder et le travail
annexe ouvert puis fermé.
D'autres tests complètent :
- `TestSubagentVueInterne` : la vue servie par le Go, rôles refusés ;
- tests unitaires : refus et leur ordre, rapports, cadrages.
+3 -1
View File
@@ -5,6 +5,7 @@ go 1.25.0
require (
codeberg.org/readeck/go-readability/v2 v2.1.2
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/caddyserver/certmagic v0.25.4
github.com/coder/websocket v1.8.15
github.com/getlantern/systray v1.2.2
@@ -13,6 +14,7 @@ require (
go.etcd.io/bbolt v1.5.0
golang.org/x/mod v0.35.0
golang.org/x/net v0.55.0
golang.org/x/sys v0.45.0
)
require (
@@ -28,6 +30,7 @@ require (
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/itlightning/dateparse v0.2.1 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@@ -45,7 +48,6 @@ require (
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
)
+67
View File
@@ -6,6 +6,8 @@ github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PF
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg=
@@ -39,8 +41,10 @@ github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
@@ -84,6 +88,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
@@ -102,24 +107,86 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+2 -1
View File
@@ -10,6 +10,7 @@ Method — in this order:
Rules:
- PLAN ONCE. If the task needs a plan, write it to PLAN.md in ONE short write, then follow it. Between tool calls, think one or two sentences at most — NEVER restate or re-derive the plan: it is in PLAN.md and in your criteria, read them instead.
- Write each file COMPLETE in a single write call. Many small writes waste turns.
- You may NOT mark a criterion passed — the verification pass does that.
- When a criterion is done AND checked, mark it completed (criteria set). Verification starts once none is left pending; only it may mark passed.
- If a command fails, read the error and fix the cause; do not retry the same command unchanged.
- Ask the user (ask tool) only for decisions that are genuinely theirs; decide the rest yourself.
- Making the change is your job; shipping it is not. Without an explicit request, never commit, push, reset or rebase, never deploy, restart a service or replace a running binary. Read-only inspection (git_status, git_diff) is fine and encouraged.
+29 -4
View File
@@ -44,20 +44,45 @@ func emitBuildLine(line string) {
// Sert à téer la sortie des commandes de runStep quand un sink est actif.
// (mutex : stdout et stderr d'une même commande peuvent écrire en parallèle)
type sinkWriter struct {
mu sync.Mutex
buf []byte
mu sync.Mutex
buf []byte
lastProg time.Time // dernière ligne de progression (\r) émise
}
// sinkProgressEvery : une barre de progression (tqdm, téléchargements de
// l'installeur de Strata) se redessine sur place avec \r, sans jamais finir sa
// ligne. On en montre une version toutes les 10 s : sans elle, un téléchargement
// de 76 Go laissait le journal figé pendant une heure ; toutes, elles le noieraient.
const sinkProgressEvery = 10 * time.Second
func (s *sinkWriter) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.buf = append(s.buf, p...)
for {
i := strings.IndexByte(string(s.buf), '\n')
i := strings.IndexAny(string(s.buf), "\r\n")
if i < 0 {
break
}
emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r"))
if s.buf[i] == '\n' {
emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r"))
s.buf = s.buf[i+1:]
continue
}
// \r : fin de ligne Windows (\r\n) ou barre qui se redessine. Le \n peut
// arriver dans l'écriture suivante : on attend de le savoir.
if i+1 >= len(s.buf) {
break
}
if s.buf[i+1] == '\n' {
emitBuildLine(string(s.buf[:i]))
s.buf = s.buf[i+2:]
continue
}
if prog := strings.TrimSpace(string(s.buf[:i])); prog != "" && time.Since(s.lastProg) >= sinkProgressEvery {
s.lastProg = time.Now()
emitBuildLine(prog)
}
s.buf = s.buf[i+1:]
}
return len(p), nil
+29
View File
@@ -0,0 +1,29 @@
package loki
import (
"reflect"
"testing"
"time"
)
// Une barre de progression (\r sans \n) doit apparaître dans le journal, sans
// le noyer ; \r\n reste une fin de ligne ordinaire, même coupé entre deux écritures.
func TestSinkWriterProgression(t *testing.T) {
var got []string
setBuildSink(func(l string) { got = append(got, l) })
defer setBuildSink(nil)
s := &sinkWriter{}
s.Write([]byte("Step 5\r"))
s.Write([]byte("\n modèle : 1 / 76 GB\r modèle : 2 / 76 GB\r"))
s.Write([]byte(" modèle : 3 / 76 GB\r"))
s.lastProg = time.Now().Add(-time.Minute) // 10 s écoulées
s.Write([]byte(" modèle : 40 / 76 GB\r[ok] fini\n"))
// « 3 » finissait sa propre écriture : il attendait de savoir si un saut de
// ligne suivait, et part donc avec l'écriture suivante.
want := []string{"Step 5", "modèle : 1 / 76 GB", "modèle : 3 / 76 GB", "[ok] fini"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("lignes %q, attendu %q", got, want)
}
}
+9 -1
View File
@@ -73,11 +73,16 @@ func writeAPIKey(key string) error {
}
// maskAPIKey renders a key for display: keep the "sk-loki-" prefix and last 4
// chars, elide the middle. Empty in → empty out.
// chars, elide the middle. Empty in → empty out. Une clé d'un ou deux octets
// n'est pas montrée du tout : k[len(k)-2:] paniquait sur un seul octet, et
// /api/apikey tombait à chaque lecture tant que la clé restait en place.
func maskAPIKey(k string) string {
if k == "" {
return ""
}
if len(k) <= 2 {
return "…"
}
if len(k) <= 13 {
return "…" + k[len(k)-2:]
}
@@ -92,6 +97,9 @@ func maskAPIKey(k string) string {
// loki set-api-key génère une clé aléatoire
// loki set-api-key "" supprime la protection
func cmdSetAPIKey(args []string) error {
if err := tuneGuard(); err != nil {
return err
}
var key string
switch {
case len(args) == 0:
+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))
+19 -6
View File
@@ -24,10 +24,11 @@ import (
)
const (
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
extKeyVision = "EXTERNAL_VISION" // "1" = le modèle distant accepte les images
)
// chatEndpoint : où partent les appels /v1/chat/completions de ce tour.
@@ -46,6 +47,14 @@ func isExternalConfig(cfg map[string]string) bool {
// externalActive : le preset actif est-il un endpoint externe ?
func externalActive() bool { return isExternalConfig(ReadConfig()) }
// externalVisionActive : le preset actif est externe ET déclaré multimodal. Pas
// de MMPROJ ni de sonde /props à consulter côté distant : seule la case du
// preset dit si le modèle accepte les images.
func externalVisionActive() bool {
cfg := ReadConfig()
return isExternalConfig(cfg) && strings.TrimSpace(cfg[extKeyVision]) == "1"
}
// completionsURL normalise l'URL saisie en une URL de complétions complète.
// Accepte les trois formes qu'on colle en pratique :
//
@@ -102,8 +111,9 @@ func (e chatEndpoint) auth(set func(k, v string)) {
// externalPresetContent construit le corps .env d'un preset externe (hors ligne
// « # NAME= », ajoutée par SavePreset). Une clé vide n'est pas écrite. `ctx` va
// dans la clé CTX standard — elle pilote la jauge de contexte et le seuil de
// compaction, exactement comme pour un preset local.
func externalPresetContent(url, model, key, ctx string) string {
// compaction, exactement comme pour un preset local. vision = le modèle distant
// accepte les images (EXTERNAL_VISION=1).
func externalPresetContent(url, model, key, ctx string, vision bool) string {
m := map[string]string{
extKeyFlag: "1",
extKeyURL: strings.TrimSpace(url),
@@ -112,6 +122,9 @@ func externalPresetContent(url, model, key, ctx string) string {
if k := strings.TrimSpace(key); k != "" {
m[extKeyToken] = k
}
if vision {
m[extKeyVision] = "1"
}
if c := strings.TrimSpace(ctx); c != "" {
m["CTX"] = c
}
@@ -0,0 +1,43 @@
package loki
import "testing"
// Preset externe actif : `loki serve` n'a aucun moteur local à lancer. Il
// sortait en erreur « MODEL non défini », que systemd (Restart=on-failure)
// relançait en boucle (amont #95).
func TestServeExitsCleanlyForExternalPreset(t *testing.T) {
testHome(t)
if err := WriteConfig(map[string]string{extKeyFlag: "1", "PORT": "8080"}); err != nil {
t.Fatal(err)
}
if err := cmdServe(nil); err != nil {
t.Fatalf("loki serve sur un preset externe : %v", err)
}
}
// Le pré-vol refusait `loki start` / `loki restart` sur un preset externe,
// en invitant à réinstaller llama.cpp.
func TestPreflightAcceptsExternalPreset(t *testing.T) {
testHome(t)
if err := WriteConfig(map[string]string{extKeyFlag: "1"}); err != nil {
t.Fatal(err)
}
if err := preflightEngine(); err != nil {
t.Fatalf("pré-vol refusé sur un preset externe : %v", err)
}
}
// Strata : le pré-vol vérifie sa configuration à lui, pas BIN/MODEL.
func TestPreflightStrataWithoutConfig(t *testing.T) {
testHome(t)
if err := WriteConfig(map[string]string{"ENGINE": "strata"}); err != nil {
t.Fatal(err)
}
err := preflightEngine()
if err == nil {
t.Fatal("un preset Strata sans STRATA_CONFIG devrait être refusé")
}
if want := "STRATA_CONFIG"; !contains(err.Error(), want) {
t.Fatalf("message attendu sur %s : %v", want, err)
}
}
+53 -4
View File
@@ -1,6 +1,7 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -46,7 +47,7 @@ func TestEndpointSuitLePresetActif(t *testing.T) {
}
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536"))); err != nil {
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536", false))); err != nil {
t.Fatal(err)
}
ep = resolveChatEndpoint()
@@ -71,7 +72,7 @@ func TestEndpointSuitLePresetActif(t *testing.T) {
func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) {
testHome(t)
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.openai.com/v1", "gpt-4o-mini", "", ""))); err != nil {
"https://api.openai.com/v1", "gpt-4o-mini", "", "", false))); err != nil {
t.Fatal(err)
}
// Une clé d'API locale existe et n'a rien à faire dans la requête sortante.
@@ -142,7 +143,7 @@ func TestRouteDeTestRemonteLErreurDeLAPI(t *testing.T) {
// d'effacement.
func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
testHome(t)
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", ""))
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", "", false))
if err != nil {
t.Fatal(err)
}
@@ -179,7 +180,7 @@ func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
// Un preset externe demande explicitement d'écraser la clé par une vide.
func TestEffacementExpliciteDeLaCle(t *testing.T) {
testHome(t)
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", ""))
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", "", false))
if err != nil {
t.Fatal(err)
}
@@ -194,3 +195,51 @@ func TestEffacementExpliciteDeLaCle(t *testing.T) {
t.Fatalf("clé = %q, attendu effacée (keyTouched avec champ vide)", k)
}
}
// Le résumé de compactage n'envoie pas chat_template_kwargs (propre à
// llama.cpp) à une API distante : une API stricte répond 400 et la compaction
// échouait à chaque fois.
func TestCompactageExterneSansKwargsLlamaCpp(t *testing.T) {
testHome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["chat_template_kwargs"]; ok {
w.WriteHeader(400)
_, _ = w.Write([]byte(`{"error":{"message":"Unrecognized request argument supplied: chat_template_kwargs"}}`))
return
}
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé"}}}})
}))
t.Cleanup(srv.Close)
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "gpt-4o-mini", "", "", false))); err != nil {
t.Fatal(err)
}
if _, err := summarizeTranscript(context.Background(), "user: bonjour\nassistant: salut"); err != nil {
t.Fatalf("compactage refusé par l'API distante : %v", err)
}
}
// Vision et benchmark d'un preset externe : la case du preset décide, et le
// bench (qui mesure le moteur local) refuse de tourner.
func TestPresetExterneVisionEtBench(t *testing.T) {
testHome(t)
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", true))); err != nil {
t.Fatal(err)
}
if !visionEnabled() || !engineSeesImages() {
t.Error("preset externe déclaré multimodal : la vision devrait être active")
}
if _, err := runBench(context.Background(), benchOpts{Prompt: 10, Predict: 10}, nil); err == nil {
t.Error("benchmark lancé sur un preset externe")
}
if got := chatModelName(); got != "gpt-4o" {
t.Errorf("badge de modèle = %q, attendu le modèle distant", got)
}
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", false))); err != nil {
t.Fatal(err)
}
if visionEnabled() {
t.Error("preset externe texte seul : la vision devrait être coupée")
}
}
+624
View File
@@ -0,0 +1,624 @@
package loki
import (
"bufio"
"encoding/binary"
"errors"
"fmt"
"io"
"math"
"math/bits"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
)
// Lecteur des métadonnées GGUF. Plusieurs réglages du moteur dépendent de ce que
// le fichier contient VRAIMENT : un modèle qui embarque une tête MTP (prédiction
// de plusieurs jetons, vérifiée exactement par le modèle cible), le nombre de
// têtes KV par couche pour estimer le cache, une architecture hybride à état
// récurrent. Jusqu'ici Loki ne le savait qu'à travers le nom du fichier, ce qui
// ne prouve rien.
//
// On ne lit que l'en-tête, la section clé/valeur et la table des tenseurs : les
// poids eux-mêmes ne sont jamais touchés, quelques Mo au plus pour un modèle de
// 30 Go. Le fichier peut être en cours de téléchargement, tronqué, ou tout
// simplement pas un GGUF : chaque longueur lue est bornée par ce qui reste du
// fichier, et la moindre incohérence donne une erreur, jamais un plantage ni une
// allocation géante. Une erreur veut dire « on ne sait pas » : l'appelant reste
// sur le comportement prudent.
// GGUFInfo résume ce que Loki retient d'un modèle GGUF. Les champs absents du
// fichier restent à zéro.
type GGUFInfo struct {
Version int // version du format (1 à 3)
Arch string // general.architecture
BlockCount int // <arch>.block_count
NextN int // <arch>.nextn_predict_layers (couches MTP annoncées)
ContextLength int // <arch>.context_length (contexte d'entraînement)
ExpertCount int // <arch>.expert_count (0 = modèle dense)
KeyLen int // <arch>.attention.key_length
ValLen int // <arch>.attention.value_length
// HeadCountKV : valeur unique, ou maximum sur les couches quand le fichier
// donne un tableau par couche (HeadCountKVLayers, nil sinon). Sur un hybride,
// les couches récurrentes y valent 0.
HeadCountKV int
HeadCountKVLayers []int
FullAttnInterval int // <arch>.full_attention_interval, si présent
Hybrid bool // au moins une clé <arch>.ssm.* : couches à état récurrent
// Repli de llama.cpp quand key_length / value_length manquent : n_embd / n_head.
EmbeddingLength int // <arch>.embedding_length
HeadCount int // <arch>.attention.head_count (valeur unique seulement)
// Taille de l'état récurrent par couche (llama_hparams::n_embd_r / n_embd_s),
// pour estimer ce que pèse un état sauvegardé d'un modèle hybride.
SSMConv int // <arch>.ssm.conv_kernel
SSMInner int // <arch>.ssm.inner_size
SSMState int // <arch>.ssm.state_size
SSMGroups int // <arch>.ssm.group_count
// HasNextNTensor : le tenseur blk.{BlockCount-1}.nextn.eh_proj.weight existe
// dans l'une des tranches. C'est le test de llama.cpp lui-même pour reconnaître
// une tête MTP : la clé nextn_predict_layers seule ne suffit pas, certains GGUF
// la gardent alors que les tenseurs ont été retirés (tête publiée à part).
HasNextNTensor bool
TensorCount int // total sur toutes les tranches
}
// Bornes de lecture. Elles sont larges devant les vrais modèles (une poignée de
// milliers de tenseurs, une cinquantaine de clés hors vocabulaire) et servent
// seulement à refuser un fichier aberrant avant d'allouer quoi que ce soit.
const (
ggufMaxString = 64 << 20 // modèle de chat le plus long connu : quelques dizaines de Ko
ggufMaxKeyLen = 64 << 10
ggufMaxTensorName = 4096 // GGML_MAX_NAME vaut 64
ggufMaxKV = 1 << 20
ggufMaxTensors = 1 << 22
ggufMaxDims = 8 // GGML_MAX_DIMS vaut 4
ggufMaxArrayDepth = 4
ggufMaxLayerArray = 1 << 16
)
// Types de valeur GGUF (gguf.h).
const (
ggufUint8 uint32 = iota
ggufInt8
ggufUint16
ggufInt16
ggufUint32
ggufInt32
ggufFloat32
ggufBool
ggufString
ggufArray
ggufUint64
ggufInt64
ggufFloat64
)
var errGGUFTruncated = errors.New("GGUF tronqué")
// ggufScalarSize : taille en octets d'un type de taille fixe, 0 sinon.
func ggufScalarSize(t uint32) int64 {
switch t {
case ggufUint8, ggufInt8, ggufBool:
return 1
case ggufUint16, ggufInt16:
return 2
case ggufUint32, ggufInt32, ggufFloat32:
return 4
case ggufUint64, ggufInt64, ggufFloat64:
return 8
}
return 0
}
// ggufReader lit séquentiellement un GGUF en comptant ce qui reste du fichier :
// toute longueur annoncée au-delà est une incohérence, détectée AVANT de lire.
type ggufReader struct {
r *bufio.Reader
bo binary.ByteOrder
v1 bool // GGUF v1 : compteurs et longueurs sur 32 bits
size int64 // taille totale du fichier
left int64 // octets restants dans le fichier
buf [8]byte
}
func (g *ggufReader) fixed(n int) ([]byte, error) {
if int64(n) > g.left {
return nil, errGGUFTruncated
}
b := g.buf[:n]
if _, err := io.ReadFull(g.r, b); err != nil {
return nil, errGGUFTruncated
}
g.left -= int64(n)
return b, nil
}
func (g *ggufReader) u32() (uint32, error) {
b, err := g.fixed(4)
if err != nil {
return 0, err
}
return g.bo.Uint32(b), nil
}
func (g *ggufReader) u64() (uint64, error) {
b, err := g.fixed(8)
if err != nil {
return 0, err
}
return g.bo.Uint64(b), nil
}
// count lit un compteur ou une longueur : 32 bits en v1, 64 bits ensuite.
func (g *ggufReader) count() (uint64, error) {
if g.v1 {
v, err := g.u32()
return uint64(v), err
}
return g.u64()
}
// countSize : taille sur disque d'un compteur, pour borner les tableaux de chaînes.
func (g *ggufReader) countSize() int64 {
if g.v1 {
return 4
}
return 8
}
func (g *ggufReader) skip(n int64) error {
if n < 0 || n > g.left {
return errGGUFTruncated
}
for rest := n; rest > 0; {
step := min(rest, 1<<30)
if _, err := g.r.Discard(int(step)); err != nil {
return errGGUFTruncated
}
rest -= step
}
g.left -= n
return nil
}
// str lit une chaîne d'au plus max octets.
func (g *ggufReader) str(max uint64) (string, error) {
n, err := g.count()
if err != nil {
return "", err
}
if n > max {
return "", fmt.Errorf("chaîne GGUF de %d octets", n)
}
if int64(n) > g.left {
return "", errGGUFTruncated
}
b := make([]byte, n)
if _, err := io.ReadFull(g.r, b); err != nil {
return "", errGGUFTruncated
}
g.left -= int64(n)
return string(b), nil
}
func (g *ggufReader) skipStr() error {
n, err := g.count()
if err != nil {
return err
}
if n > ggufMaxString {
return fmt.Errorf("chaîne GGUF de %d octets", n)
}
return g.skip(int64(n))
}
// intVal lit une valeur entière (ou booléenne) de type t. ok vaut false pour les
// flottants, lus et ignorés : aucune des clés retenues n'en est un.
func (g *ggufReader) intVal(t uint32) (v int64, ok bool, err error) {
size := ggufScalarSize(t)
if size == 0 {
return 0, false, fmt.Errorf("type GGUF %d inattendu", t)
}
b, err := g.fixed(int(size))
if err != nil {
return 0, false, err
}
switch t {
case ggufUint8, ggufBool:
return int64(b[0]), true, nil
case ggufInt8:
return int64(int8(b[0])), true, nil
case ggufUint16:
return int64(g.bo.Uint16(b)), true, nil
case ggufInt16:
return int64(int16(g.bo.Uint16(b))), true, nil
case ggufUint32:
return int64(g.bo.Uint32(b)), true, nil
case ggufInt32:
return int64(int32(g.bo.Uint32(b))), true, nil
case ggufUint64:
u := g.bo.Uint64(b)
if u > math.MaxInt64 {
u = math.MaxInt64
}
return int64(u), true, nil
case ggufInt64:
return int64(g.bo.Uint64(b)), true, nil
}
return 0, false, nil // flottant
}
// skipArray saute n éléments de type et, tableaux imbriqués compris (profondeur
// bornée). Le vocabulaire (150 000 chaînes) passe par ici sans rien allouer.
func (g *ggufReader) skipArray(et uint32, n uint64, depth int) error {
if depth > ggufMaxArrayDepth {
return errors.New("tableaux GGUF trop imbriqués")
}
if size := ggufScalarSize(et); size > 0 {
if n > uint64(g.left/size) {
return errGGUFTruncated
}
return g.skip(int64(n) * size)
}
switch et {
case ggufString:
if n > uint64(g.left/g.countSize()) {
return errGGUFTruncated
}
for i := uint64(0); i < n; i++ {
if err := g.skipStr(); err != nil {
return err
}
}
return nil
case ggufArray:
// Chaque sous-tableau coûte au moins son type et son compteur.
if n > uint64(g.left/(4+g.countSize())) {
return errGGUFTruncated
}
for i := uint64(0); i < n; i++ {
sub, err := g.u32()
if err != nil {
return err
}
sn, err := g.count()
if err != nil {
return err
}
if err := g.skipArray(sub, sn, depth+1); err != nil {
return err
}
}
return nil
}
return fmt.Errorf("type GGUF %d inconnu", et)
}
// ggufFile : ce qu'on retient d'UNE tranche avant de résoudre les clés selon
// l'architecture (general.architecture n'est pas forcément la première clé).
type ggufFile struct {
version int
arch string
ints map[string]int64
arrays map[string][]int64 // tableaux d'entiers retenus (têtes KV par couche)
keys []string // noms de toutes les clés, pour repérer <arch>.ssm.*
tensors int
nextn []string // noms des tenseurs *.nextn.eh_proj.weight
}
// keepIntArray : les seuls tableaux qu'on lit au lieu de les sauter.
func keepIntArray(key string) bool {
return strings.HasSuffix(key, ".attention.head_count_kv")
}
// readGGUFHeader lit l'en-tête, les clés et la table des tenseurs d'un fichier.
func readGGUFHeader(path string) (*ggufFile, error) {
fh, err := os.Open(path)
if err != nil {
return nil, err
}
defer fh.Close()
st, err := fh.Stat()
if err != nil {
return nil, err
}
if st.IsDir() {
return nil, fmt.Errorf("%s est un dossier", baseName(path))
}
f, err := parseGGUF(fh, st.Size())
if err != nil {
return nil, fmt.Errorf("%s : %w", baseName(path), err)
}
return f, nil
}
// parseGGUF lit size octets au plus de r. Un panic éventuel (fichier
// pathologique non prévu par les bornes) est rattrapé en erreur : lire des
// métadonnées ne doit jamais faire tomber Loki.
func parseGGUF(r io.Reader, size int64) (f *ggufFile, err error) {
defer func() {
if p := recover(); p != nil {
f, err = nil, fmt.Errorf("GGUF illisible : %v", p)
}
}()
g := &ggufReader{r: bufio.NewReaderSize(r, 1<<20), bo: binary.LittleEndian, size: size, left: size}
magic, err := g.fixed(4)
if err != nil {
return nil, err
}
if string(magic) != "GGUF" {
return nil, errors.New("pas un fichier GGUF")
}
ver, err := g.u32()
if err != nil {
return nil, err
}
// Les GGUF gros-boutistes (s390x) gardent la même signature : seule la
// version lue à l'envers trahit l'ordre des octets.
if ver&0xFFFF == 0 {
if sw := bits.ReverseBytes32(ver); sw >= 1 && sw <= 3 {
g.bo, ver = binary.BigEndian, sw
}
}
if ver < 1 || ver > 3 {
return nil, fmt.Errorf("version GGUF %d non prise en charge", ver)
}
g.v1 = ver == 1
nTensors, err := g.count()
if err != nil {
return nil, err
}
nKV, err := g.count()
if err != nil {
return nil, err
}
if nKV > ggufMaxKV || nTensors > ggufMaxTensors {
return nil, fmt.Errorf("compteurs GGUF aberrants (%d clés, %d tenseurs)", nKV, nTensors)
}
f = &ggufFile{version: int(ver), ints: map[string]int64{}, arrays: map[string][]int64{}, tensors: int(nTensors)}
for i := uint64(0); i < nKV; i++ {
key, err := g.str(ggufMaxKeyLen)
if err != nil {
return nil, err
}
t, err := g.u32()
if err != nil {
return nil, err
}
f.keys = append(f.keys, key)
switch t {
case ggufString:
if key == "general.architecture" {
if f.arch, err = g.str(ggufMaxKeyLen); err != nil {
return nil, err
}
} else if err := g.skipStr(); err != nil {
return nil, err
}
case ggufArray:
et, err := g.u32()
if err != nil {
return nil, err
}
n, err := g.count()
if err != nil {
return nil, err
}
if keepIntArray(key) && et != ggufFloat32 && et != ggufFloat64 && ggufScalarSize(et) > 0 && n <= ggufMaxLayerArray {
vals := make([]int64, 0, min(n, uint64(g.left)))
for j := uint64(0); j < n; j++ {
v, _, err := g.intVal(et)
if err != nil {
return nil, err
}
vals = append(vals, v)
}
f.arrays[key] = vals
} else if err := g.skipArray(et, n, 1); err != nil {
return nil, err
}
default:
v, ok, err := g.intVal(t)
if err != nil {
return nil, err
}
if ok {
f.ints[key] = v
}
}
}
dimSize := int64(8)
if g.v1 {
dimSize = 4
}
var maxOff uint64
for i := uint64(0); i < nTensors; i++ {
name, err := g.str(ggufMaxTensorName)
if err != nil {
return nil, err
}
nd, err := g.u32()
if err != nil {
return nil, err
}
if nd > ggufMaxDims {
return nil, fmt.Errorf("tenseur %q à %d dimensions", name, nd)
}
// dimensions et type (u32), puis position des données (u64)
if err := g.skip(int64(nd)*dimSize + 4); err != nil {
return nil, err
}
off, err := g.u64()
if err != nil {
return nil, err
}
maxOff = max(maxOff, off)
if strings.HasSuffix(name, ".nextn.eh_proj.weight") {
f.nextn = append(f.nextn, name)
}
}
// Les données commencent au prochain multiple de general.alignment. Le début
// du dernier tenseur doit tenir dans le fichier : un GGUF coupé dans ses poids
// (copie interrompue, disque plein) a un en-tête intact, il faut le voir
// quand même. On ne connaît pas la taille de chaque tenseur sans la table des
// types ggml : vérifier son premier octet suffit à attraper l'essentiel.
align := int64(32)
if a, ok := f.ints["general.alignment"]; ok {
if a <= 0 || a > 1<<20 || a&(a-1) != 0 {
return nil, fmt.Errorf("alignement GGUF %d invalide", a)
}
align = a
}
if nTensors > 0 {
pos := g.size - g.left
dataStart := (pos + align - 1) / align * align
if maxOff >= uint64(g.size) || dataStart+int64(maxOff) >= g.size {
return nil, errGGUFTruncated
}
}
return f, nil
}
// clampInt ramène une valeur de métadonnée dans [0, MaxInt32] : un champ négatif
// ou démesuré n'a pas de sens pour un compte de couches ou de têtes.
func clampInt(v int64) int {
if v < 0 {
return 0
}
if v > math.MaxInt32 {
return math.MaxInt32
}
return int(v)
}
// ggufInfoFrom résout les clés de la première tranche selon l'architecture et
// cherche la tête MTP dans les tenseurs de toutes les tranches.
func ggufInfoFrom(first *ggufFile, all []*ggufFile) GGUFInfo {
in := GGUFInfo{Version: first.version, Arch: first.arch}
p := first.arch + "."
get := func(k string) int { return clampInt(first.ints[p+k]) }
in.BlockCount = get("block_count")
in.NextN = get("nextn_predict_layers")
in.ContextLength = get("context_length")
in.ExpertCount = get("expert_count")
in.KeyLen = get("attention.key_length")
in.ValLen = get("attention.value_length")
in.FullAttnInterval = get("full_attention_interval")
in.EmbeddingLength = get("embedding_length")
in.HeadCount = get("attention.head_count")
in.SSMConv = get("ssm.conv_kernel")
in.SSMInner = get("ssm.inner_size")
in.SSMState = get("ssm.state_size")
in.SSMGroups = get("ssm.group_count")
in.HeadCountKV = get("attention.head_count_kv")
if arr, ok := first.arrays[p+"attention.head_count_kv"]; ok {
in.HeadCountKVLayers = make([]int, len(arr))
in.HeadCountKV = 0
for i, v := range arr {
in.HeadCountKVLayers[i] = clampInt(v)
in.HeadCountKV = max(in.HeadCountKV, in.HeadCountKVLayers[i])
}
}
if first.arch != "" {
for _, k := range first.keys {
if strings.HasPrefix(k, p+"ssm.") {
in.Hybrid = true
break
}
}
}
want := ""
if in.BlockCount > 0 {
want = "blk." + strconv.Itoa(in.BlockCount-1) + ".nextn.eh_proj.weight"
}
for _, f := range all {
in.TensorCount += f.tensors
for _, n := range f.nextn {
if n == want {
in.HasNextNTensor = true
}
}
}
return in
}
// Cache par chemin : la même liste de modèles est consultée à chaque démarrage
// et à chaque affichage. L'empreinte (taille et date de chaque tranche) suffit à
// voir un fichier remplacé ou un téléchargement terminé ; les erreurs ne sont
// pas gardées, un fichier en cours de téléchargement sera relu une fois complet.
type ggufCacheEntry struct {
stamp string
info GGUFInfo
}
var (
ggufCacheMu sync.Mutex
ggufCache = map[string]ggufCacheEntry{}
)
const ggufCacheMax = 256
// ggufMeta lit les métadonnées du modèle désigné par path. Pour un modèle en
// tranches, path peut désigner n'importe laquelle : les clés viennent de la
// première (les suivantes ne portent que split.*), les tenseurs de toutes. Une
// tranche manquante est une erreur : le modèle ne démarrerait pas, et on ne
// saurait pas dire si la tête MTP s'y trouvait.
func ggufMeta(path string) (GGUFInfo, error) {
path = filepath.Clean(path)
dir := filepath.Dir(path)
fam := shardFamily(path)
paths := make([]string, len(fam))
var stamp strings.Builder
for i, n := range fam {
paths[i] = filepath.Join(dir, n)
st, err := os.Stat(paths[i])
if err != nil {
return GGUFInfo{}, err
}
if st.IsDir() {
return GGUFInfo{}, fmt.Errorf("%s est un dossier", n)
}
fmt.Fprintf(&stamp, "%d:%d;", st.Size(), st.ModTime().UnixNano())
}
key := paths[0]
ggufCacheMu.Lock()
e, ok := ggufCache[key]
ggufCacheMu.Unlock()
if ok && e.stamp == stamp.String() {
return cloneGGUFInfo(e.info), nil
}
files := make([]*ggufFile, 0, len(paths))
for _, p := range paths {
f, err := readGGUFHeader(p)
if err != nil {
return GGUFInfo{}, err
}
files = append(files, f)
}
info := ggufInfoFrom(files[0], files)
ggufCacheMu.Lock()
if len(ggufCache) >= ggufCacheMax {
ggufCache = map[string]ggufCacheEntry{}
}
ggufCache[key] = ggufCacheEntry{stamp: stamp.String(), info: info}
ggufCacheMu.Unlock()
return cloneGGUFInfo(info), nil
}
// cloneGGUFInfo : l'appelant peut modifier le tableau par couche sans toucher au
// cache.
func cloneGGUFInfo(in GGUFInfo) GGUFInfo {
if in.HeadCountKVLayers != nil {
in.HeadCountKVLayers = append([]int(nil), in.HeadCountKVLayers...)
}
return in
}
+417
View File
@@ -0,0 +1,417 @@
package loki
import (
"bytes"
"encoding/binary"
"math"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
// ggufBuilder écrit un GGUF synthétique : en-tête, clés typées, table des
// tenseurs, sans aucune donnée de poids (le lecteur n'en lit jamais).
type ggufBuilder struct {
bo binary.ByteOrder
version uint32
nKV int
nT int
kv bytes.Buffer
tensors bytes.Buffer
}
func newGGUF() *ggufBuilder { return &ggufBuilder{bo: binary.LittleEndian, version: 3} }
func (b *ggufBuilder) cnt(w *bytes.Buffer, n uint64) {
if b.version == 1 {
binary.Write(w, b.bo, uint32(n))
} else {
binary.Write(w, b.bo, n)
}
}
func (b *ggufBuilder) str(w *bytes.Buffer, s string) {
b.cnt(w, uint64(len(s)))
w.WriteString(s)
}
func (b *ggufBuilder) key(k string, t uint32) {
b.nKV++
b.str(&b.kv, k)
binary.Write(&b.kv, b.bo, t)
}
func (b *ggufBuilder) u32(k string, v uint32) *ggufBuilder {
b.key(k, ggufUint32)
binary.Write(&b.kv, b.bo, v)
return b
}
func (b *ggufBuilder) i64(k string, v int64) *ggufBuilder {
b.key(k, ggufInt64)
binary.Write(&b.kv, b.bo, v)
return b
}
func (b *ggufBuilder) f32(k string, v float32) *ggufBuilder {
b.key(k, ggufFloat32)
binary.Write(&b.kv, b.bo, math.Float32bits(v))
return b
}
func (b *ggufBuilder) boolean(k string, v bool) *ggufBuilder {
b.key(k, ggufBool)
if v {
b.kv.WriteByte(1)
} else {
b.kv.WriteByte(0)
}
return b
}
func (b *ggufBuilder) s(k, v string) *ggufBuilder {
b.key(k, ggufString)
b.str(&b.kv, v)
return b
}
func (b *ggufBuilder) strArr(k string, vs []string) *ggufBuilder {
b.key(k, ggufArray)
binary.Write(&b.kv, b.bo, ggufString)
b.cnt(&b.kv, uint64(len(vs)))
for _, v := range vs {
b.str(&b.kv, v)
}
return b
}
func (b *ggufBuilder) i32Arr(k string, vs []int32) *ggufBuilder {
b.key(k, ggufArray)
binary.Write(&b.kv, b.bo, ggufInt32)
b.cnt(&b.kv, uint64(len(vs)))
for _, v := range vs {
binary.Write(&b.kv, b.bo, v)
}
return b
}
// nested : tableau de deux tableaux de u8, pour vérifier le saut récursif.
func (b *ggufBuilder) nested(k string) *ggufBuilder {
b.key(k, ggufArray)
binary.Write(&b.kv, b.bo, ggufArray)
b.cnt(&b.kv, 2)
for i := 0; i < 2; i++ {
binary.Write(&b.kv, b.bo, ggufUint8)
b.cnt(&b.kv, 3)
b.kv.Write([]byte{1, 2, 3})
}
return b
}
func (b *ggufBuilder) tensor(name string, dims ...uint64) *ggufBuilder {
b.str(&b.tensors, name)
binary.Write(&b.tensors, b.bo, uint32(len(dims)))
for _, d := range dims {
if b.version == 1 {
binary.Write(&b.tensors, b.bo, uint32(d))
} else {
binary.Write(&b.tensors, b.bo, d)
}
}
binary.Write(&b.tensors, b.bo, uint32(0)) // type F32
binary.Write(&b.tensors, b.bo, uint64(b.nT*32)) // position des données
b.nT++
return b
}
func (b *ggufBuilder) bytes() []byte {
var out bytes.Buffer
out.WriteString("GGUF")
binary.Write(&out, b.bo, b.version)
b.cnt(&out, uint64(b.nT))
b.cnt(&out, uint64(b.nKV))
out.Write(b.kv.Bytes())
out.Write(b.tensors.Bytes())
// bourrage jusqu'à l'alignement par défaut, puis 32 octets de « poids » par
// tenseur
if b.nT > 0 {
out.Write(make([]byte, (32-out.Len()%32)%32+32*b.nT))
}
return out.Bytes()
}
func writeGGUF(t *testing.T, dir, name string, data []byte) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, data, 0o644); err != nil {
t.Fatal(err)
}
return p
}
// qwenMTP : modèle hybride à 4 couches, têtes KV par couche (0 sur les couches
// récurrentes), tête MTP sur la dernière couche, vocabulaire à sauter.
func qwenMTP(b *ggufBuilder) *ggufBuilder {
return b.u32("general.alignment", 32).
strArr("tokenizer.ggml.tokens", []string{"<s>", "a", "bb", strings.Repeat("c", 300)}).
s("general.architecture", "qwen35").
s("general.name", "Qwen synthétique").
u32("qwen35.block_count", 4).
u32("qwen35.nextn_predict_layers", 1).
i64("qwen35.context_length", 262144).
u32("qwen35.attention.key_length", 256).
u32("qwen35.attention.value_length", 256).
i32Arr("qwen35.attention.head_count_kv", []int32{0, 0, 0, 4}).
u32("qwen35.full_attention_interval", 4).
u32("qwen35.ssm.conv_kernel", 4).
f32("qwen35.rope.freq_base", 1e7).
boolean("tokenizer.ggml.add_bos_token", false).
nested("bidon.imbrique").
tensor("token_embd.weight", 1024, 32000).
tensor("blk.0.attn_norm.weight", 1024).
tensor("blk.3.nextn.eh_proj.weight", 2048, 1024)
}
func TestGGUFMetaQwenHybrideMTP(t *testing.T) {
p := writeGGUF(t, t.TempDir(), "qwen.gguf", qwenMTP(newGGUF()).bytes())
got, err := ggufMeta(p)
if err != nil {
t.Fatal(err)
}
want := GGUFInfo{
Version: 3, Arch: "qwen35", BlockCount: 4, NextN: 1, ContextLength: 262144,
KeyLen: 256, ValLen: 256, HeadCountKV: 4, HeadCountKVLayers: []int{0, 0, 0, 4},
FullAttnInterval: 4, Hybrid: true, SSMConv: 4, HasNextNTensor: true, TensorCount: 3,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ggufMeta =\n%+v\nattendu\n%+v", got, want)
}
}
// La clé nextn_predict_layers ne prouve rien : seul le tenseur de la DERNIÈRE
// couche compte, comme dans llama.cpp.
func TestGGUFMetaTeteMTPAbsenteOuMalPlacee(t *testing.T) {
dir := t.TempDir()
base := func() *ggufBuilder {
return newGGUF().s("general.architecture", "glm4moe").
u32("glm4moe.block_count", 47).
u32("glm4moe.nextn_predict_layers", 1).
u32("glm4moe.expert_count", 128).
u32("glm4moe.attention.head_count_kv", 8)
}
cases := []struct {
name string
b *ggufBuilder
}{
{"sans-tenseur.gguf", base().tensor("blk.45.ffn_up.weight", 8, 8)},
{"mauvaise-couche.gguf", base().tensor("blk.45.nextn.eh_proj.weight", 8, 8)},
}
for _, c := range cases {
got, err := ggufMeta(writeGGUF(t, dir, c.name, c.b.bytes()))
if err != nil {
t.Fatalf("%s : %v", c.name, err)
}
if got.HasNextNTensor || got.NextN != 1 || got.ExpertCount != 128 || got.HeadCountKV != 8 ||
got.HeadCountKVLayers != nil || got.Hybrid {
t.Fatalf("%s : %+v", c.name, got)
}
}
got, err := ggufMeta(writeGGUF(t, dir, "ok.gguf", base().tensor("blk.46.nextn.eh_proj.weight", 8, 8).bytes()))
if err != nil || !got.HasNextNTensor {
t.Fatalf("tête MTP en dernière couche non vue : %+v, %v", got, err)
}
}
// Modèle en tranches : les clés sont dans la première, la tête MTP dans la
// dernière. N'importe quelle tranche peut être désignée.
func TestGGUFMetaTranches(t *testing.T) {
dir := t.TempDir()
first := newGGUF().s("general.architecture", "qwen35").u32("qwen35.block_count", 4).
u32("split.count", 2).tensor("token_embd.weight", 8, 8)
second := newGGUF().u32("split.no", 1).u32("split.count", 2).
tensor("blk.3.nextn.eh_proj.weight", 8, 8).tensor("output.weight", 8, 8)
p1 := writeGGUF(t, dir, "m-00001-of-00002.gguf", first.bytes())
writeGGUF(t, dir, "m-00002-of-00002.gguf", second.bytes())
for _, p := range []string{p1, filepath.Join(dir, "m-00002-of-00002.gguf")} {
got, err := ggufMeta(p)
if err != nil {
t.Fatal(err)
}
if got.Arch != "qwen35" || !got.HasNextNTensor || got.TensorCount != 3 {
t.Fatalf("%s : %+v", baseName(p), got)
}
}
// tranche manquante : on ne sait pas, donc erreur
writeGGUF(t, dir, "n-00001-of-00002.gguf", first.bytes())
if _, err := ggufMeta(filepath.Join(dir, "n-00001-of-00002.gguf")); err == nil {
t.Fatal("tranche manquante acceptée")
}
// gguf-split --no-tensor-first-split : la première tranche ne porte que les
// clés, aucun tenseur ni aucune donnée. Elle doit passer telle quelle.
meta := newGGUF().s("general.architecture", "glm4moe").u32("glm4moe.block_count", 47).
u32("split.count", 2)
rest := newGGUF().u32("split.no", 1).tensor("blk.46.nextn.eh_proj.weight", 8, 8)
writeGGUF(t, dir, "s-00001-of-00002.gguf", meta.bytes())
writeGGUF(t, dir, "s-00002-of-00002.gguf", rest.bytes())
got, err := ggufMeta(filepath.Join(dir, "s-00001-of-00002.gguf"))
if err != nil || got.Arch != "glm4moe" || !got.HasNextNTensor || got.TensorCount != 1 {
t.Fatalf("première tranche sans tenseur : %+v, %v", got, err)
}
}
// v1 (compteurs 32 bits) et gros-boutiste : même résultat que le v3 ordinaire.
func TestGGUFMetaVersionsEtOrdre(t *testing.T) {
dir := t.TempDir()
v1 := newGGUF()
v1.version = 1
v2 := newGGUF()
v2.version = 2
be := newGGUF()
be.bo = binary.BigEndian
for name, b := range map[string]*ggufBuilder{"v1.gguf": v1, "v2.gguf": v2, "be.gguf": be} {
got, err := ggufMeta(writeGGUF(t, dir, name, qwenMTP(b).bytes()))
if err != nil {
t.Fatalf("%s : %v", name, err)
}
if got.Arch != "qwen35" || got.BlockCount != 4 || !got.HasNextNTensor ||
!reflect.DeepEqual(got.HeadCountKVLayers, []int{0, 0, 0, 4}) || got.Version != int(b.version) {
t.Fatalf("%s : %+v", name, got)
}
}
}
// Fichier en cours de téléchargement : chaque coupure donne une erreur, jamais
// un panic ni un faux résultat.
func TestGGUFMetaTronque(t *testing.T) {
dir := t.TempDir()
full := qwenMTP(newGGUF()).bytes()
// Toutes les coupures jusqu'au premier octet du dernier tenseur (le seul que
// le lecteur sait situer sans la table des types ggml).
for cut := 0; cut <= len(full)-32; cut++ {
if _, err := parseGGUF(bytes.NewReader(full[:cut]), int64(cut)); err == nil {
t.Fatalf("coupure à %d/%d acceptée", cut, len(full))
}
}
if _, err := parseGGUF(bytes.NewReader(full), int64(len(full))); err != nil {
t.Fatalf("fichier complet refusé : %v", err)
}
// et par le chemin fichier, pour une coupure dans l'en-tête
if _, err := ggufMeta(writeGGUF(t, dir, "cut.gguf", full[:len(full)/3])); err == nil {
t.Fatal("fichier coupé accepté")
}
}
func TestGGUFMetaFichiersAberrants(t *testing.T) {
dir := t.TempDir()
hdr := func(ver uint32, nT, nKV uint64) *bytes.Buffer {
var w bytes.Buffer
w.WriteString("GGUF")
binary.Write(&w, binary.LittleEndian, ver)
binary.Write(&w, binary.LittleEndian, nT)
binary.Write(&w, binary.LittleEndian, nKV)
return &w
}
// clé annoncée à 1 Eo
hugeKey := hdr(3, 0, 1)
binary.Write(hugeKey, binary.LittleEndian, uint64(1)<<60)
// tableau de chaînes annoncé à 2^62 éléments
hugeArr := hdr(3, 0, 1)
binary.Write(hugeArr, binary.LittleEndian, uint64(1))
hugeArr.WriteString("k")
binary.Write(hugeArr, binary.LittleEndian, ggufArray)
binary.Write(hugeArr, binary.LittleEndian, ggufString)
binary.Write(hugeArr, binary.LittleEndian, uint64(1)<<62)
// type de valeur inconnu
badType := hdr(3, 0, 1)
binary.Write(badType, binary.LittleEndian, uint64(1))
badType.WriteString("k")
binary.Write(badType, binary.LittleEndian, uint32(99))
binary.Write(badType, binary.LittleEndian, uint64(0))
// tenseur à 1000 dimensions
badDims := hdr(3, 1, 0)
binary.Write(badDims, binary.LittleEndian, uint64(1))
badDims.WriteString("t")
binary.Write(badDims, binary.LittleEndian, uint32(1000))
badDims.Write(make([]byte, 64))
cases := map[string][]byte{
"vide": nil,
"alignement": newGGUF().u32("general.alignment", 24).tensor("t", 1).bytes(),
"signature": []byte("GGML\x03\x00\x00\x00" + strings.Repeat("\x00", 16)),
"version": hdr(4, 0, 0).Bytes(),
"compteurs": hdr(3, 1<<40, 1<<40).Bytes(),
"cle-geante": hugeKey.Bytes(),
"tableau": hugeArr.Bytes(),
"type-inconnu": badType.Bytes(),
"dimensions": badDims.Bytes(),
}
for name, data := range cases {
if _, err := ggufMeta(writeGGUF(t, dir, name+".gguf", data)); err == nil {
t.Errorf("%s : accepté", name)
}
}
if _, err := ggufMeta(filepath.Join(dir, "absent.gguf")); err == nil {
t.Error("fichier absent accepté")
}
}
// Octet corrompu n'importe où dans l'en-tête (v1 et v3) : le lecteur doit rendre
// la main vite, sans panic remonté ni allocation démesurée. Les valeurs 0xFF et
// 0x7F transforment un compteur en nombre géant, le cas qui ferait mal.
func TestGGUFMetaOctetCorrompu(t *testing.T) {
v1 := newGGUF()
v1.version = 1
for _, b := range []*ggufBuilder{newGGUF(), v1} {
full := qwenMTP(b).bytes()
hdr := len(full) - 3*32 // le bourrage et les « poids » ne sont jamais lus
for i := 0; i < hdr; i++ {
for _, v := range []byte{0xFF, 0x7F, 0x00} {
mut := append([]byte(nil), full...)
mut[i] = v
// le filet de sécurité existe, mais aucune entrée ne doit y tomber
if _, err := parseGGUF(bytes.NewReader(mut), int64(len(mut))); err != nil &&
strings.Contains(err.Error(), "illisible") {
t.Fatalf("v%d, octet %d = %#x : %v", b.version, i, v, err)
}
}
}
}
}
// Le cache tient tant que taille et date sont les mêmes, et se renouvelle dès
// qu'elles changent (téléchargement terminé, fichier remplacé).
func TestGGUFMetaCache(t *testing.T) {
dir := t.TempDir()
data := qwenMTP(newGGUF()).bytes()
p := writeGGUF(t, dir, "c.gguf", data)
stamp := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
if err := os.Chtimes(p, stamp, stamp); err != nil {
t.Fatal(err)
}
a, err := ggufMeta(p)
if err != nil {
t.Fatal(err)
}
a.HeadCountKVLayers[0] = 99 // ne doit pas atteindre le cache
// même taille, même date, contenu illisible : le cache répond
writeGGUF(t, dir, "c.gguf", make([]byte, len(data)))
if err := os.Chtimes(p, stamp, stamp); err != nil {
t.Fatal(err)
}
b, err := ggufMeta(p)
if err != nil || b.HeadCountKVLayers[0] != 0 || !b.HasNextNTensor {
t.Fatalf("cache non utilisé ou altéré : %+v, %v", b, err)
}
// date différente : relecture, donc erreur sur le contenu illisible
if err := os.Chtimes(p, stamp.Add(time.Second), stamp.Add(time.Second)); err != nil {
t.Fatal(err)
}
if _, err := ggufMeta(p); err == nil {
t.Fatal("fichier modifié servi depuis le cache")
}
}
+124 -10
View File
@@ -2,11 +2,14 @@ package loki
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
// backend_gpu.go — sélection du/des GPU utilisés par llama-server.
@@ -26,6 +29,39 @@ type gpuInfo struct {
MemTotal string // en MiB
MemUsed string
Cap string // compute capability
// Liaison PCIe et horloge mémoire, pour guider le placement entre cartes
// inégales (voir annotateDevices). Zéro = inconnu : pilote trop ancien,
// « [N/A] », ou requête retombée sur les seuls champs historiques.
BusID string
LinkGen int // génération PCIe ACTUELLE — elle descend au repos (économie d'énergie)
LinkGenMax int // génération maximale négociable carte + carte mère
LinkWidth int // largeur actuelle (×N), elle aussi réduite au repos sur certaines cartes
LinkWidthMax int
MemClockMax int // horloge mémoire maximale, MHz
}
// gpuBaseFields : la requête historique de detectGPUs, que tout pilote connaît.
// gpuLinkFields : les champs de liaison, ajoutés en fin de ligne. nvidia-smi
// refuse TOUTE la requête au moindre champ inconnu (« is not a valid field to
// query ») : la largeur du bus mémoire, qui n'existe pas, n'y figure donc pas,
// et un refus fait retomber sur la requête historique — jamais de GPU perdu
// pour une information de confort.
const (
gpuBaseFields = "index,name,memory.total,memory.used,compute_cap"
gpuLinkFields = "pci.bus_id,pcie.link.gen.current,pcie.link.gen.max,pcie.link.width.current,pcie.link.width.max,clocks.max.memory"
)
// nvidiaSmiGPUQuery lance « nvidia-smi --query-gpu=<fields> ». Variable pour
// que les tests substituent un faux nvidia-smi. Borné dans le temps comme la
// lecture des jauges (nvidiaSmiTimeout) : un pilote coincé ne doit pas figer
// « loki gpu » ni l'éditeur de preset.
var nvidiaSmiGPUQuery = func(fields string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), nvidiaSmiTimeout)
defer cancel()
cmd := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu="+fields, "--format=csv,noheader,nounits"))
cmd.WaitDelay = time.Second
return cmd.Output()
}
func cmdGPU(args []string) error {
@@ -83,6 +119,9 @@ func gpuList() error {
for _, g := range gpus {
mark := " "
line := fmt.Sprintf("[%d] %s — %s/%s MiB (cc %s)", g.Index, g.Name, g.MemUsed, g.MemTotal, g.Cap)
if l := g.linkSummary(); l != "" {
line += " " + l
}
active := sel == "" || selected[g.Index]
if sel != "" && selected[g.Index] {
mark = green("● ")
@@ -109,6 +148,10 @@ func gpuList() error {
// gpuSet writes (or clears) CUDA_VISIBLE_DEVICES in config.env then offers a
// restart so the change takes effect.
func gpuSet(value string) error {
// La sélection GPU relance le moteur : pas pendant une optimisation.
if err := tuneGuard(); err != nil {
return err
}
if err := SetConfigKey("CUDA_VISIBLE_DEVICES", value); err != nil {
return err
}
@@ -131,14 +174,46 @@ func detectGPUs() ([]gpuInfo, error) {
if !hasTool("nvidia-smi") {
return nil, fmt.Errorf("nvidia-smi introuvable — sélection GPU disponible uniquement sur NVIDIA")
}
out, err := hideCmd(exec.Command("nvidia-smi",
"--query-gpu=index,name,memory.total,memory.used,compute_cap",
"--format=csv,noheader,nounits")).Output()
return queryGPUs(nvidiaSmiGPUQuery)
}
// queryGPUs : le cœur de detectGPUs, la requête passée en paramètre pour être
// testée sans nvidia-smi. UNE invocation d'ordinaire : champs historiques et
// liaison ensemble. La seconde n'a lieu que si nvidia-smi refuse un champ de
// liaison (pilote ancien) — pas sur un délai dépassé, qu'une relance ne ferait
// que doubler.
func queryGPUs(query func(fields string) ([]byte, error)) ([]gpuInfo, error) {
out, err := query(gpuBaseFields + "," + gpuLinkFields)
if err != nil && smiFieldRefused(out, err) {
out, err = query(gpuBaseFields)
}
if err != nil {
return nil, fmt.Errorf("nvidia-smi a échoué: %w", err)
}
gpus := parseGPUQuery(string(out))
if len(gpus) == 0 {
return nil, fmt.Errorf("aucun GPU NVIDIA détecté")
}
return gpus, nil
}
// smiFieldRefused : nvidia-smi a-t-il rejeté un champ de la requête ? Il
// l'écrit sur sa sortie standard et sort en code 2 (argument invalide).
func smiFieldRefused(out []byte, err error) bool {
if strings.Contains(string(out), "valid field") {
return true
}
var ee *exec.ExitError
return errors.As(err, &ee) && ee.ExitCode() == 2
}
// parseGPUQuery lit la sortie CSV de detectGPUs, avec ou sans les champs de
// liaison. Fonction pure. Une ligne de moins de cinq colonnes est ignorée,
// comme avant ; une valeur de liaison illisible (« [N/A] », « [Not
// Supported] ») vaut zéro, sans faire tomber la carte.
func parseGPUQuery(out string) []gpuInfo {
var gpus []gpuInfo
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
parts := strings.Split(line, ",")
if len(parts) < 5 {
continue
@@ -147,12 +222,51 @@ func detectGPUs() ([]gpuInfo, error) {
parts[i] = strings.TrimSpace(parts[i])
}
idx, _ := strconv.Atoi(parts[0])
gpus = append(gpus, gpuInfo{
Index: idx, Name: parts[1], MemTotal: parts[2], MemUsed: parts[3], Cap: parts[4],
})
g := gpuInfo{Index: idx, Name: parts[1], MemTotal: parts[2], MemUsed: parts[3], Cap: parts[4]}
if len(parts) >= 11 {
if b := parts[5]; b != "" && b != "N/A" && !strings.HasPrefix(b, "[") {
g.BusID = b
}
g.LinkGen, g.LinkGenMax = smiNum(parts[6]), smiNum(parts[7])
g.LinkWidth, g.LinkWidthMax = smiNum(parts[8]), smiNum(parts[9])
g.MemClockMax = smiNum(parts[10])
}
gpus = append(gpus, g)
}
if len(gpus) == 0 {
return nil, fmt.Errorf("aucun GPU NVIDIA détecté")
return gpus
}
// smiNum lit un entier de nvidia-smi ; « [N/A] », vide ou autre chose = 0.
func smiNum(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n < 0 {
return 0
}
return gpus, nil
return n
}
// linkSummary : la liaison PCIe en clair, « PCIe 4 ×16 », maximum d'abord —
// l'actuel descend au repos et ne dit rien de la carte sous charge. Vide =
// inconnue.
func (g gpuInfo) linkSummary() string {
if g.LinkGenMax == 0 && g.LinkWidthMax == 0 {
return ""
}
s := "PCIe"
if g.LinkGenMax > 0 {
s += " " + strconv.Itoa(g.LinkGenMax)
}
if g.LinkWidthMax > 0 {
s += " ×" + strconv.Itoa(g.LinkWidthMax)
}
if (g.LinkGen > 0 && g.LinkGen < g.LinkGenMax) || (g.LinkWidth > 0 && g.LinkWidth < g.LinkWidthMax) {
known := func(n int) string {
if n <= 0 {
return "?"
}
return strconv.Itoa(n)
}
s += " (actuellement " + known(g.LinkGen) + " ×" + known(g.LinkWidth) + ", réduit au repos)"
}
return s
}
+88
View File
@@ -0,0 +1,88 @@
package loki
import (
"errors"
"strings"
"testing"
)
// La sortie enrichie : liaison et horloge lues, « [N/A] » et « [Not
// Supported] » valent zéro sans faire tomber la carte.
func TestParseGPUQueryLiaison(t *testing.T) {
out := `0, NVIDIA GeForce RTX 5060 Ti, 16311, 900, 12.0, 00000000:01:00.0, 1, 5, 8, 8, 14001
1, NVIDIA GeForce RTX 3060, 12288, 300, 8.6, 00000000:05:00.0, [N/A], 4, [Not Supported], 16, [N/A]
`
g := parseGPUQuery(out)
if len(g) != 2 {
t.Fatalf("%d carte(s), attendu 2 : %+v", len(g), g)
}
if g[0].BusID != "00000000:01:00.0" || g[0].LinkGen != 1 || g[0].LinkGenMax != 5 ||
g[0].LinkWidth != 8 || g[0].LinkWidthMax != 8 || g[0].MemClockMax != 14001 {
t.Errorf("carte 0 mal lue : %+v", g[0])
}
if g[1].LinkGen != 0 || g[1].LinkGenMax != 4 || g[1].LinkWidth != 0 || g[1].LinkWidthMax != 16 || g[1].MemClockMax != 0 {
t.Errorf("valeurs [N/A] mal tolérées : %+v", g[1])
}
if g[1].MemTotal != "12288" || g[1].Cap != "8.6" {
t.Errorf("champs historiques perdus : %+v", g[1])
}
if s := g[0].linkSummary(); s != "PCIe 5 ×8 (actuellement 1 ×8, réduit au repos)" {
t.Errorf("résumé de liaison : %q", s)
}
if s := g[1].linkSummary(); s != "PCIe 4 ×16" {
t.Errorf("résumé sans valeur actuelle : %q", s)
}
}
// La sortie historique (cinq colonnes) se lit comme avant, sans liaison.
func TestParseGPUQueryHistorique(t *testing.T) {
g := parseGPUQuery("0, NVIDIA GeForce RTX 3080 Ti, 12288, 1442, 8.6\n")
if len(g) != 1 || g[0].Name != "NVIDIA GeForce RTX 3080 Ti" || g[0].MemTotal != "12288" {
t.Fatalf("lecture historique cassée : %+v", g)
}
if g[0].LinkGenMax != 0 || g[0].BusID != "" || g[0].linkSummary() != "" {
t.Errorf("liaison inventée : %+v", g[0])
}
}
// Un pilote qui refuse un champ de liaison : la requête historique prend le
// relais, les cartes restent détectées. Une autre erreur (délai dépassé) n'est
// pas relancée.
func TestQueryGPUsRepli(t *testing.T) {
var calls []string
refuse := func(fields string) ([]byte, error) {
calls = append(calls, fields)
if strings.Contains(fields, "pcie") {
return []byte(`Field "pcie.link.gen.max" is not a valid field to query.` + "\n"), errors.New("exit status 2")
}
return []byte("0, NVIDIA GeForce RTX 3060, 12288, 300, 8.6\n"), nil
}
g, err := queryGPUs(refuse)
if err != nil || len(g) != 1 || g[0].Name != "NVIDIA GeForce RTX 3060" {
t.Fatalf("repli raté : %+v, %v", g, err)
}
if len(calls) != 2 || calls[1] != gpuBaseFields {
t.Errorf("requêtes : %q", calls)
}
calls = nil
hang := func(fields string) ([]byte, error) {
calls = append(calls, fields)
return nil, errors.New("signal: killed")
}
if _, err := queryGPUs(hang); err == nil {
t.Error("erreur avalée")
}
if len(calls) != 1 {
t.Errorf("un délai dépassé relancé : %d requêtes", len(calls))
}
calls = nil
ok := func(fields string) ([]byte, error) {
calls = append(calls, fields)
return []byte("0, NVIDIA GeForce RTX 3060, 12288, 300, 8.6, 00000000:05:00.0, 4, 4, 16, 16, 7501\n"), nil
}
if g, err := queryGPUs(ok); err != nil || len(calls) != 1 || g[0].LinkWidthMax != 16 {
t.Errorf("une seule requête attendue : %d, %+v, %v", len(calls), g, err)
}
}
+3
View File
@@ -217,6 +217,9 @@ func llamacppInstall(args []string) error {
// ---------------------------------------------------------------------------
func llamacppUpdate(args []string) error {
if err := tuneGuard(); err != nil {
return err
}
ref := ""
clean := false
noRestart := false
+4 -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 à
+21 -15
View File
@@ -29,7 +29,8 @@ import (
"time"
)
const llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"
// llamaReleasesAPI : variable pour que les tests la pointent sur un faux GitHub.
var llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"
type ghAsset struct {
Name string `json:"name"`
@@ -41,18 +42,25 @@ func prebuiltDir() string {
return filepath.Join(backendsDir(), "llama.cpp-prebuilt")
}
// prebuiltVersionFields : les champs du marqueur VERSION, « tag cudaVer
// format ». La version CUDA est vide hors CUDA Windows : « b300 fmt2 » n'a
// alors que DEUX champs, et le format est le second.
func prebuiltVersionFields() []string {
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
if err != nil {
return nil
}
return strings.Fields(strings.TrimSpace(string(b)))
}
// prebuiltVersion lit le marqueur VERSION du dossier prebuilt : "tag cudaVer"
// (cudaVer vide hors CUDA Windows).
func prebuiltVersion() (tag, cudaVer string) {
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
if err != nil {
return "", ""
}
f := strings.Fields(strings.TrimSpace(string(b)))
f := prebuiltVersionFields()
if len(f) > 0 {
tag = f[0]
}
if len(f) > 1 {
if len(f) > 1 && !strings.HasPrefix(f[1], "fmt") {
cudaVer = f[1]
}
return
@@ -64,15 +72,13 @@ func prebuiltVersion() (tag, cudaVer string) {
// sera refaite au lieu d'être déclarée « déjà à jour ».
const prebuiltFormat = "fmt2"
// prebuiltVersionFormat lit le 3e champ du marqueur VERSION ("" si absent).
// prebuiltVersionFormat lit le format, dernier champ du marqueur VERSION (""
// si absent). Lu jadis comme 3e champ, il manquait dès que la version CUDA
// était vide (Linux, macOS) : chaque vérification réinstallait la release.
func prebuiltVersionFormat() string {
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
if err != nil {
return ""
}
f := strings.Fields(strings.TrimSpace(string(b)))
if len(f) > 2 {
return f[2]
f := prebuiltVersionFields()
if len(f) > 1 && strings.HasPrefix(f[len(f)-1], "fmt") {
return f[len(f)-1]
}
return ""
}
+84 -3
View File
@@ -113,7 +113,69 @@ func ListPresets() ([]Preset, error) {
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
return applyPresetOrder(out), nil
}
// presetOrderKey : ordre d'affichage choisi par glisser-déposer dans l'UI
// (repris d'AJEAN v0.10.9), une liste d'IDs persistée en base.
const presetOrderKey = "preset_order"
func loadPresetOrder() []string {
var ids []string
getJSON(bkState, presetOrderKey, &ids)
return ids
}
func savePresetOrder(ids []string) error { return putJSON(bkState, presetOrderKey, ids) }
// applyPresetOrder réordonne `list` (déjà trié par nom) selon l'ordre stocké :
// d'abord les presets classés dans l'ordre choisi, puis les non classés
// (fraîchement créés) dans leur ordre alphabétique. Tri STABLE.
func applyPresetOrder(list []Preset) []Preset {
order := loadPresetOrder()
if len(order) == 0 {
return list
}
pos := map[string]int{}
for i, id := range order {
pos[id] = i
}
sort.SliceStable(list, func(i, j int) bool {
pi, oki := pos[list[i].ID]
pj, okj := pos[list[j].ID]
if oki && okj {
return pi < pj
}
if oki != okj {
return oki // un preset classé passe avant un non classé
}
return false // deux non classés : l'ordre alphabétique reste
})
return list
}
// presetCtx : la taille de contexte d'un preset, formatée pour la pastille de
// la liste (« 32K », « 128K », « 1M »). CTX absent = 32768, le défaut de serve.
func presetCtx(content string) string {
raw := strings.TrimSpace(parseEnv(content)["CTX"])
if raw == "" {
raw = "32768"
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return ""
}
// Multiple de 1024 (32768, 131072…) : écrit comme on le pense, « 32K »,
// « 128K » ; sinon décimal, comme on le tape (40000 → « 40K »).
switch {
case n >= 1024 && n%1024 == 0 && n < 1<<20:
return strconv.Itoa(n/1024) + "K"
case n >= 1000000:
return strings.TrimSuffix(strconv.FormatFloat(float64(n)/1000000, 'f', 1, 64), ".0") + "M"
case n >= 1000:
return strconv.Itoa((n+500)/1000) + "K"
}
return strconv.Itoa(n)
}
// uniquePresetID derives a unique filename id from a display name, appending
@@ -189,7 +251,13 @@ func safePresetPath(name string) (string, error) {
// version effaçait la clé, et le moteur repartait sur le défaut « toutes les
// interfaces » : une machine volontairement fermée se rouvrait toute seule au
// premier changement de preset.
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST"}
// CUDA_LAUNCH_QUEUES (files de lancement CUDA, voir launchQueuesEnv) suit la
// même logique : un « off » posé parce que CETTE machine bloque ne doit pas
// disparaître à la bascule suivante.
// SLOT_PERSIST (état du slot gardé à la bascule, backend_serve_persist.go) est
// par nature un réglage de bascule : posé sur A seulement, il disparaîtrait en
// passant sur B, dont le lancement effacerait l'état gardé de A.
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST", "CUDA_LAUNCH_QUEUES", "SLOT_PERSIST"}
// softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit
// pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un
@@ -199,7 +267,8 @@ var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VIS
// inconditionnellement écrasait cette valeur par l'ancienne (mono-GPU) : le
// modèle se retrouvait entièrement sur une seule carte et mourait sur
// « cudaMalloc failed: out of memory ». Le preset explicite gagne.
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true}
// CUDA_LAUNCH_QUEUES aussi : un preset peut l'imposer pour son modèle.
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true, "CUDA_LAUNCH_QUEUES": true, "SLOT_PERSIST": true}
// applyPresetFile installe le preset comme configuration active, en réinjectant
// les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être
@@ -260,6 +329,13 @@ func presetImposesEngine(p string) bool {
// SwitchToPreset installe le preset et redémarre le service. Les réglages
// « appareil » (preservedKeys) sont conservés à travers la bascule.
func SwitchToPreset(target string) error {
if err := tuneGuard(); err != nil {
return err
}
// SLOT_PERSIST : relevé sur la configuration d'avant, gardé juste avant
// l'arrêt (llm_slotpersist.go). Sans la clé, ou hors du process web (rien
// n'y dit ce que porte le slot) : nil, rien ne se passe.
persist := slotPersistPrepare()
if err := applyPresetFile(target); err != nil {
return err
}
@@ -273,9 +349,11 @@ func SwitchToPreset(target string) error {
if !serviceIsActive() {
return nil
}
persist.save()
return serviceAction("stop")
}
fmt.Println(dim("[info] redémarrage du service..."))
persist.save()
return serviceAction("restart")
}
@@ -432,6 +510,9 @@ func DeletePreset(id string) error {
// sans ce ménage, il resterait orphelin et ressusciterait sur un preset recréé
// sous le même id.
_ = putStr(bkState, sysPromptKeyPrefix+id, "")
// Même ménage pour son benchmark : un preset recréé sous cet id en
// hériterait, alors qu'il a été mesuré sur un autre modèle.
deletePresetBench(id)
return nil
}
@@ -0,0 +1,60 @@
package loki
import (
"os"
"path/filepath"
"testing"
)
// Ordre choisi par glisser-déposer : les presets classés d'abord, dans cet
// ordre ; les nouveaux (non classés) ensuite, par nom.
func TestPresetOrder(t *testing.T) {
testHome(t)
_ = os.MkdirAll(presetsDir(), 0o755)
for _, id := range []string{"alpha", "bravo", "charlie", "delta"} {
if err := os.WriteFile(filepath.Join(presetsDir(), id+".env"), []byte("# NAME="+id+"\nCTX=4096\n"), 0o644); err != nil {
t.Fatal(err)
}
}
ids := func() []string {
list, err := ListPresets()
if err != nil {
t.Fatal(err)
}
var out []string
for _, p := range list {
out = append(out, p.ID)
}
return out
}
if got := ids(); got[0] != "alpha" || got[3] != "delta" {
t.Fatalf("sans ordre enregistré, tri par nom attendu : %v", got)
}
if err := savePresetOrder([]string{"charlie", "alpha"}); err != nil {
t.Fatal(err)
}
want := []string{"charlie", "alpha", "bravo", "delta"}
got := ids()
for i := range want {
if got[i] != want[i] {
t.Fatalf("ordre %v, attendu %v", got, want)
}
}
}
func TestPresetCtx(t *testing.T) {
cases := map[string]string{
"": "32K", // défaut de serve : 32768
"CTX=40000\n": "40K",
"CTX=\"131072\"\n": "128K",
"CTX=1000000\n": "1M",
"CTX=1500000\n": "1.5M",
"CTX=512\n": "512",
"CTX=abc\n": "",
}
for in, want := range cases {
if got := presetCtx(in); got != want {
t.Errorf("presetCtx(%q) = %q, attendu %q", in, got, want)
}
}
}
+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)
+566 -93
View File
@@ -4,15 +4,18 @@ import (
"bytes"
"context"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
// binSupportsReasoningFlag dit si ce llama-server accepte « --reasoning ».
// helpSupportsReasoningFlag dit si ce llama-server accepte « --reasoning ».
//
// Le drapeau est récent : les moteurs plus anciens, et certains forks, ne le
// connaissent pas et REFUSENT de démarrer sur un argument inconnu. Comme on ne
@@ -22,9 +25,11 @@ import (
// L'aide se lit avec le même chemin de bibliothèques que le vrai lancement
// (setLibraryPath a déjà été appelé) : sans ça un moteur parfaitement valide
// échoue à s'exécuter (« libllama-common.so introuvable ») et on conclurait à
// tort qu'il ne gère pas le drapeau.
func binSupportsReasoningFlag(bin string) bool {
return strings.Contains(binHelp(bin), "--reasoning ")
// tort qu'il ne gère pas le drapeau. Les tests de capacité prennent le TEXTE de
// l'aide, pas le binaire : buildServeArgs reste ainsi une fonction pure,
// testable avec une aide fabriquée.
func helpSupportsReasoningFlag(help string) bool {
return strings.Contains(help, "--reasoning ")
}
// binHelp lit « <bin> --help » UNE fois par binaire et garde le texte. Plusieurs
@@ -56,20 +61,19 @@ func binHelp(bin string) string {
return h
}
// binSupportsLoadMode : les moteurs récents ont fusionné --mlock, --mmap et
// helpSupportsLoadMode : les moteurs récents ont fusionné --mlock, --mmap et
// --no-mmap dans un seul --load-mode ; les anciens ne connaissent que les trois
// drapeaux d'origine. On traduit donc à l'exécution, sans réécrire le preset :
// le même EXTRA_ARGS reste lançable sur les deux générations de moteur.
func binSupportsLoadMode(bin string) bool {
return strings.Contains(binHelp(bin), "--load-mode")
func helpSupportsLoadMode(help string) bool {
return strings.Contains(help, "--load-mode")
}
// binSupportsNGLAuto : « -ngl auto » laisse llama.cpp mesurer la VRAM libre et
// helpSupportsNGLAuto : « -ngl auto » laisse llama.cpp mesurer la VRAM libre et
// choisir le nombre de couches. Imposer un nombre (999 compris) désarme ce
// calcul — « n_gpu_layers already set by user to 999, abort » — et le moteur
// tente alors de tout mettre sur le GPU, quitte à échouer en cudaMalloc.
func binSupportsNGLAuto(bin string) bool {
h := binHelp(bin)
func helpSupportsNGLAuto(h string) bool {
i := strings.Index(h, "--n-gpu-layers")
if i < 0 {
i = strings.Index(h, "-ngl")
@@ -84,16 +88,16 @@ func binSupportsNGLAuto(bin string) bool {
return strings.Contains(h[i:end], "'auto'")
}
// binFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
// helpFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
//
// La lecture fine de l'aide (binSupportsNGLAuto) reste la source de vérité, mais
// La lecture fine de l'aide (helpSupportsNGLAuto) reste la source de vérité, mais
// elle dépend de la mise en page d'un texte d'aide — une description reformulée
// ou une colonne plus large, et on conclut « non » sur un moteur parfaitement
// capable, donc on lui réimpose 999 et l'abandon revient. --load-mode est arrivé
// dans la même vague que « -ngl auto » : sa présence sert de second témoin, plus
// grossier mais insensible à la mise en page.
func binFitsLayersItself(bin string) bool {
return binSupportsNGLAuto(bin) || binSupportsLoadMode(bin)
func helpFitsLayersItself(help string) bool {
return helpSupportsNGLAuto(help) || helpSupportsLoadMode(help)
}
// nglArgs traduit la valeur NGL du preset en arguments, et renvoie au passage la
@@ -140,22 +144,37 @@ func hasAnyFlag(args []string, flags ...string) bool {
return false
}
// normalizeLoadFlags traduit les drapeaux de chargement dépréciés vers
// --load-mode, sur les moteurs qui le connaissent :
// normalizeLoadFlags choisit, au lancement, la forme des drapeaux de chargement
// que CE binaire comprend. llama.cpp récent a remplacé --mlock / --no-mmap par
// --load-mode (et refuse les anciens) ; un moteur ancien ou un fork ne connaît
// que les anciens. L'éditeur écrit --load-mode, un preset d'avant peut porter
// les anciens : on traduit dans le sens qu'il faut (repris d'AJEAN 0.16.0).
//
// --mlock → --load-mode mlock (garder le modèle en RAM)
// --no-mmap → --load-mode none (tout charger, pas de mmap)
// --mmap → --load-mode mmap
// Vers --load-mode (moteur récent). Deux interrupteurs → une seule valeur, au
// sens où llama.cpp l'entend (« mlock » = PAS de mmap + résident) :
//
// Les deux à la fois (--mlock --no-mmap) : mlock l'emporte, c'est l'intention la
// plus forte (résident en RAM, jamais rendu au système). Un --load-mode déjà
// écrit à la main dans EXTRA_ARGS gagne sur tout : on retire alors simplement
// les vieux drapeaux. Sur un moteur ancien, rien n'est touché.
func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
// --mlock seul → mmap+mlock (l'ancien sens de --mlock : mmap conservé)
// --no-mmap seul → none
// --mlock + --no-mmap → mlock
// --mmap → mmap
//
// ⚠️ Jusqu'ici --mlock seul devenait « mlock », qui COUPE le mmap : le modèle
// entier montait en RAM au lieu d'être mappé — un OOM assuré sur un modèle plus
// gros que la mémoire. Un --load-mode déjà écrit gagne sur tout : on retire
// alors simplement les vieux drapeaux.
//
// Vers les anciens drapeaux (moteur qui ne connaît pas --load-mode) :
//
// none → --no-mmap · mlock → --mlock --no-mmap · mmap+mlock → --mlock
// auto, mmap, dio → rien (défaut de l'ancien moteur)
//
// La note éventuelle (dio abandonné) est RENDUE, pas affichée : buildServeArgs
// reste pure et c'est cmdServe qui l'écrit, à sa place parmi les autres notes.
func normalizeLoadFlags(args []string, supportsLoadMode bool) ([]string, string) {
if !supportsLoadMode {
return args
return downgradeLoadMode(args)
}
explicit := hasAnyFlag(args, "--load-mode")
explicit := hasAnyFlag(args, "--load-mode", "-lm")
mlock, nommap, mmap := false, false, false
out := make([]string, 0, len(args)+2)
for _, a := range args {
@@ -171,23 +190,102 @@ func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
}
}
if explicit || !(mlock || nommap || mmap) {
return out
return out, ""
}
mode := "mmap"
switch {
case mlock:
case mlock && nommap:
mode = "mlock"
case mlock:
mode = "mmap+mlock"
case nommap:
mode = "none"
}
return append(out, "--load-mode", mode)
return append(out, "--load-mode", mode), ""
}
// downgradeLoadMode retraduit un --load-mode pour un moteur qui ne le connaît
// pas : le lui passer tel quel le ferait sortir en erreur au démarrage.
func downgradeLoadMode(args []string) ([]string, string) {
mode, found := "", false
kept := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--load-mode" || a == "-lm" {
found = true
if i+1 < len(args) {
mode = strings.ToLower(args[i+1])
i++
}
continue
}
if v, ok := strings.CutPrefix(a, "--load-mode="); ok {
found, mode = true, strings.ToLower(v)
continue
}
kept = append(kept, a)
}
if !found {
return args, ""
}
add := func(flag string) {
for _, a := range kept {
if a == flag {
return
}
}
kept = append(kept, flag)
}
switch mode {
case "none":
add("--no-mmap")
case "mlock":
add("--mlock")
add("--no-mmap")
case "mmap+mlock":
add("--mlock")
case "dio":
return kept, "ce moteur ne connaît pas --load-mode dio (DirectIO) : chargement par défaut"
}
return kept, ""
}
// cmdServe replaces the historic start.sh: read config.env, build the
// llama-server invocation, and exec it (replacing this process so systemd
// supervises llama-server directly).
//
// Tout ce qui touche au monde (fichiers, aide du moteur, variables
// d'environnement, port, exec) reste ICI ; la composition de la ligne de
// commande vit dans buildServeArgs, pure, pour qu'un changement de drapeau se
// vérifie en table de tests plutôt qu'en relançant un moteur.
func cmdServe(args []string) error {
cfg := ReadConfig()
// Moteur d'ESSAI de l'optimiseur (backend_tune_run.go) : sa configuration
// est une copie posée dans le dossier de l'essai, jamais config.env, et rien
// de ce que le vrai moteur laisse derrière lui (état des slots, jeton de
// SPEC=auto) n'est touché. Sans la variable, rien ne change.
trialDir := os.Getenv(tuneTrialEnv)
if trialDir != "" {
c, err := readTuneTrialConfig(trialDir)
if err != nil {
return err
}
cfg = c
}
// API externe : aucun moteur local. On sort SANS erreur, sinon systemd
// (Restart=on-failure) relance en boucle « MODEL non défini » (amont #95).
if isExternalConfig(cfg) {
fmt.Println("[info] preset externe actif : pas de moteur local à lancer")
return nil
}
// Preset ENGINE=strata (backend_strata.go) : un autre moteur, lancé par son
// propre serveur. L'optimiseur ne règle que llama-server.
if isStrataConfig(cfg) {
if trialDir != "" {
return fmt.Errorf("l'optimiseur ne s'applique pas à un preset Strata")
}
return serveStrata(cfg)
}
bin := cfg["BIN"]
if bin == "" {
return fmt.Errorf("BIN non défini — lance « loki edit »")
@@ -230,40 +328,327 @@ func cmdServe(args []string) error {
// Linux, PATH on Windows — handled inside execServer.
setLibraryPath(filepath.Dir(bin))
// Sélection GPU (loki gpu) : on filtre les devices visibles par llama-server.
// CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index correspondent à ceux
// affichés par nvidia-smi (sinon CUDA réordonne par "device le plus rapide").
if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" {
_ = os.Setenv("CUDA_VISIBLE_DEVICES", v)
_ = os.Setenv("CUDA_DEVICE_ORDER", "PCI_BUS_ID")
// La sélection GPU est posée AVANT de lire l'aide du moteur, comme elle l'a
// toujours été : le moteur interrogé voit déjà les mêmes devices que celui
// qu'on lancera. buildServeArgs la renvoie aussi dans env (même valeur).
applyServeEnv(cudaDeviceEnv(cfg))
si := serveSysInfo{Help: binHelp(bin), Model: model}
// Vision : le projecteur multimodal (mmproj-*.gguf) donne des yeux au modèle.
// C'est un fichier .gguf À PART du modèle, chargé via --mmproj. On le résout
// comme le modèle (nom simple cherché dans les dossiers déclarés, ou chemin
// absolu), pour qu'un preset écrit sous Windows reste lançable ailleurs et que
// le champ « Vision » de l'interface n'ait qu'à écrire le nom du fichier.
// Introuvable = on préfère le dire clairement plutôt que laisser llama-server
// mourir en boucle sur un « failed to load mmproj » cryptique.
if mm := strings.TrimSpace(cfg["MMPROJ"]); mm != "" {
mmPath, err := resolveServeModelPath(mm)
if err != nil {
return fmt.Errorf("projecteur vision introuvable : %s (%v)", mm, err)
}
if _, err := os.Stat(mmPath); err != nil {
return fmt.Errorf("projecteur vision introuvable : %s", mmPath)
}
si.MMProj = mmPath
}
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
si.APIKey, _ = effectiveAPIKeyErr()
// Conteneur à l'étroit (cpuset, quota) : seul Linux l'expose, dans /proc et
// /sys. Un LLAMA_ARG_THREADS déjà posé est un choix de l'utilisateur — un -t
// de Loki l'écraserait, donc on ne sonde même pas.
if runtime.GOOS == "linux" && os.Getenv("LLAMA_ARG_THREADS") == "" {
si.CPU = cpusetThreads(os.DirFS("/"))
}
extra := splitArgs(cfg["EXTRA_ARGS"])
// Deux GPU ou plus : de quoi décider des files de lancement CUDA (voir
// launchQueuesEnv). Après la sélection GPU, qui fait partie de la réponse.
probeServeGPUs(cfg, extra, &si)
probeExpertEnv(&si)
probeLoadEnv(&si)
probeFidelityEnv(&si)
probeCacheRAM(cfg, extra, &si)
probeCkptEnv(&si)
probeSideSlotEnv(cfg, &si)
// SPLIT_MODE=tensor seulement : cartes listées par CE moteur, dans son ordre.
probeSplit(cfg, bin, &si)
// LOAD_GUARD : un refus envisagé sur la VRAM NVIDIA se décide sur les
// cartes du moteur (Vulkan sur cartes mixtes, ROCm…). Sinon rien n'est lu.
normExtra, _ := normalizeLoadFlags(extra, helpSupportsLoadMode(si.Help))
probeLoadDevices(cfg, normExtra, bin, &si)
spec := specMode(cfg)
if spec != "off" && spec != "" {
probeSpec(cfg, &si)
}
// Build du moteur : seulement pour un hybride ou SPEC=auto, les seuls cas où
// il décide de quelque chose (voir ckptArgs, specAutoBlocker) — inutile de
// lancer « --version » sinon.
if ggufHybrid(si.GGUF) || spec == "auto" {
si.EngineBuild = engineBuildTrusted(bin)
}
// Lu seulement ici ; rien n'est consommé avant que le port soit libre.
specMark := specAutoMark{FP: configFingerprint(cfg), Bin: bin, Build: si.EngineBuild}
specRecord := false
if spec == "auto" {
si.SpecAutoBlocked, specRecord = specAutoPeek(specMark)
}
probeSlotPersistEnv(cfg, &si)
if strings.Contains(si.Help, "--slot-save-path") && !hasAnyFlag(extra, "--slot-save-path") {
if trialDir != "" {
// L'essai a son propre dossier : celui du vrai moteur garde ses états.
si.SlotDir = prepareSlotDir(trialDir, false)
} else {
si.SlotDir = prepareSlotDir(LokiHome(), slotPersistOn(cfg))
}
}
llmArgs, env, notes := buildServeArgs(cfg, extra, bin, si)
applyServeEnv(env)
// SLOT_PERSIST (backend_serve_persist.go) : la clé de CE lancement, et les
// états gardés au-delà de deux retirés. Refusé pour ce preset (brouillon,
// poids sur CPU…) : ceux des autres presets restent, pour leur retour.
// Clé retirée : plus aucun état gardé sur le disque.
persistKey := ""
persistOK, _ := slotPersistPlan(cfg, extra, si)
switch {
case trialDir != "":
// Un essai ne garde rien et n'efface rien.
case persistOK:
persistKey = slotPersistKey(llmArgs, env, si, bin)
slotPersistPrune(si.SlotDir)
case !slotPersistOn(cfg):
slotPersistPurge(filepath.Join(LokiHome(), "slots"))
}
for _, n := range notes {
fmt.Fprintln(os.Stderr, "[loki serve] "+n)
}
// Chargement résident avec plus de poids en RAM que la machine n'en a :
// échec certain (voir loadModeRisk), LOAD_GUARD=off pour passer outre.
if _, refuse := loadModeRisk(cfg, llmArgs, si); refuse != "" {
return loadGuardRefusal(refuse, trialDir != "")
}
// Essai : écoute sur 127.0.0.1 et le port libre choisi par l'optimiseur,
// même si EXTRA_ARGS en nomme d'autres (le dernier --port gagne). La ligne
// composée est rendue à l'optimiseur, qui la compare à celle de référence
// AVANT de charger quoi que ce soit ; à blanc, on s'arrête là.
if trialDir != "" {
llmArgs = tuneForceAddr(llmArgs, cfg["HOST"], cfg["PORT"])
l := tuneLaunch{Bin: bin, Args: llmArgs, Env: env, Notes: notes, Probe: tuneProbeFrom(si)}
if err := writeTuneLaunch(trialDir, l); err != nil {
return err
}
if os.Getenv(tuneDryRunEnv) != "" {
return nil
}
}
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
// mmproj-F16.gguf) still resolve.
_ = os.Chdir(LokiHome())
// Port déjà pris (souvent un llama-server orphelin qu'un stop n'a pas pu
// tuer) : deux moteurs sur un même port se partagent les requêtes au hasard,
// VRAM saturée et réponses du mauvais modèle. On refuse en clair.
host, port := argValue(llmArgs, "--host"), argValue(llmArgs, "--port")
if err := waitPortFree(host, port, 5*time.Second); err != nil {
return err
}
// Le type EFFECTIF : un -ctk/-ctv d'EXTRA_ARGS l'emporte sur KV_TYPE, et c'est
// lui que le moteur accélère ou non.
kt, vt, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
warnSlowKV(kt, vt)
// Jeton de tentative : l'ancien consommé (et l'échec inscrit), le nouveau
// posé, au dernier moment — port libre, l'ancien moteur est donc bien parti
// et le process web ne peut pas le confondre avec lui.
if spec == "auto" && trialDir == "" {
_, _, auto := specArgs(cfg, extra, si)
specAutoSettle(specMark, si.SpecAutoBlocked, specRecord, auto)
}
// Posée au dernier moment, port libre : l'ancien moteur est parti, la clé
// lue par le process web est celle du moteur qui répondra.
if trialDir == "" {
writeSlotPersistMarker(LokiHome(), persistKey)
}
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
bin, filepath.Base(model), port)
// Ligne de commande réellement lancée, gardée pour l'interface (amont #108).
if trialDir == "" {
recordEngineCmdline(env, llmArgs)
}
// Hand off to the llama-server process. On Unix this replaces the current
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
return execServer(bin, llmArgs)
}
// serveSysInfo rassemble ce que cmdServe a dû demander au monde avant de
// composer la ligne de commande : l'aide du moteur (d'où se déduisent ses
// capacités), les chemins déjà résolus et vérifiés, la clé d'API. Avec elle,
// buildServeArgs n'a plus besoin de toucher ni au disque ni au binaire.
type serveSysInfo struct {
Help string // sortie de « <bin> --help » ; vide = moteur inconnu, aucun risque pris
Model string // chemin du .gguf principal, résolu et vérifié
MMProj string // chemin du projecteur vision, résolu et vérifié ; vide = pas de vision
APIKey string // clé effective (.api_key, sinon config.env) ; vide = serveur ouvert
CPU cpuBudget // sonde de conteneur (Linux) ; zéro = llama.cpp choisit ses threads seul
LaunchQueues string // CUDA_SCALE_LAUNCH_QUEUES déjà dans l'environnement : choix de l'utilisateur, intouché
GPUs int // GPU visibles (CUDA_VISIBLE_DEVICES, sinon nvidia-smi) ; 0 = inconnu ou non sondé
ArgEnv map[string]string // LLAMA_ARG_* de l'environnement qui décident du pipeline, de --fit et du cache (serveArgEnv, fitArgEnv, fidelityArgEnv)
UserEnv map[string]string // GGML_* des réglages d'expert déjà posés (voir expertEnvKeys) : intouchés
// Cache de prompts (voir backend_serve_cache.go). Zéro = inconnu : le moteur
// garde son défaut.
GGUF *GGUFInfo // métadonnées du modèle ; nil = illisibles
ModelBytes int64 // taille du modèle, toutes tranches
RAMMiB int64 // RAM de la machine, limite du conteneur comprise
RAMAvailMiB int64 // RAM libre au lancement
VRAMMiB int64 // VRAM NVIDIA des cartes visibles
UnifiedMem bool // macOS : RAM et VRAM ne font qu'une (voir loadModeRisk)
// VRAM des cartes listées par le moteur lui-même (--list-devices), lue
// seulement quand LOAD_GUARD envisage un refus (probeLoadDevices) ; 0 =
// non lue : un avis au plus, jamais de refus sur la seule VRAM NVIDIA.
EngineVRAMMiB int64
SlotDir string // dossier de --slot-save-path, créé et vérifié ; vide = pas de drapeau
// Build d'un moteur officiel (engineBuildTrusted), lu seulement pour un
// modèle hybride ou SPEC=auto ; 0 = inconnu ou non lu : ni avis ni drapeau
// automatique.
EngineBuild int
// Décodage spéculatif (voir backend_serve_spec.go), lu seulement si SPEC le
// demande.
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles
DraftIsModel bool // MODEL_DRAFT désigne le modèle principal lui-même (voir specArgs)
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
// Cartes listées par le moteur (--list-devices), lues seulement pour
// SPLIT_MODE=tensor (voir backend_serve_split.go) ; vide = inconnues.
SplitDevs []splitDev
}
// cudaDeviceEnv : sélection GPU (loki gpu), on filtre les devices visibles par
// llama-server. CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index
// correspondent à ceux affichés par nvidia-smi (sinon CUDA réordonne par
// « device le plus rapide »).
func cudaDeviceEnv(cfg map[string]string) map[string]string {
env := map[string]string{}
if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" {
env["CUDA_VISIBLE_DEVICES"] = v
env["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID"
}
return env
}
func applyServeEnv(env map[string]string) {
for k, v := range env {
_ = os.Setenv(k, v)
}
}
// serveKVTypes donne les types de cache K et V du preset : KV_TYPE pour les
// deux, KV_TYPE_K / KV_TYPE_V pour les régler séparément. Vide = défaut moteur.
func serveKVTypes(cfg map[string]string) (k, v string) {
kv := cfg["KV_TYPE"]
k, v = kv, kv
if s := cfg["KV_TYPE_K"]; s != "" {
k = s
}
if s := cfg["KV_TYPE_V"]; s != "" {
v = s
}
return k, v
}
// buildServeArgs compose la ligne de commande de llama-server (bin en tête) à
// partir du preset, d'EXTRA_ARGS tel que découpé par splitArgs et de ce que
// cmdServe a sondé. Fonction pure : elle renvoie aussi les variables
// d'environnement à poser et les notes à afficher, sans rien faire elle-même.
// L'ordre des arguments est celui qu'a toujours produit cmdServe — le moteur
// retient la DERNIÈRE occurrence d'un drapeau, donc l'ordre fait partie du
// comportement.
func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveSysInfo) (args []string, env map[string]string, notes []string) {
get := func(key, fallback string) string {
if v, ok := cfg[key]; ok && v != "" {
return v
}
return fallback
}
kv := get("KV_TYPE", "")
ktv := get("KV_TYPE_K", kv)
vtv := get("KV_TYPE_V", kv)
ktv, vtv := serveKVTypes(cfg)
env = cudaDeviceEnv(cfg)
// EXTRA_ARGS est lu ICI, avant de composer la ligne de commande : ce que
// l'utilisateur a écrit à la main décide des défauts que Loki a le droit
// d'ajouter (voir hasAnyFlag). Il est aussi traduit vers les drapeaux de
// chargement actuels quand le moteur les attend (voir normalizeLoadFlags).
extra := normalizeLoadFlags(splitArgs(cfg["EXTRA_ARGS"]), binSupportsLoadMode(bin))
extra, loadNote := normalizeLoadFlags(extra, helpSupportsLoadMode(si.Help))
if loadNote != "" {
notes = append(notes, loadNote)
}
llmArgs := []string{bin,
"-m", model,
"-c", get("CTX", "32768"),
"-t", get("THREADS", "0"),
"-tb", get("THREADS_BATCH", "0"),
// Parallélisme de tenseurs (SPLIT_MODE=tensor, voir backend_serve_split.go) :
// sans la clé, splitOn est faux et rien de ce qui suit ne change. Accepté, il
// compte pour les réglages qui lisent le placement dans EXTRA_ARGS (files de
// lancement, graphes CUDA, --fit) comme un -sm tensor écrit à la main.
splitOn, splitA, splitNGLArgs, splitEnv, splitNotes := splitTensorArgs(cfg, extra, si)
place := extra
if splitOn {
place = append(append([]string{}, extra...), "-sm", "tensor")
}
for k, v := range splitEnv {
env[k] = v
}
notes = append(notes, splitNotes...)
// Files de lancement CUDA : une variable d'environnement, pas un drapeau —
// la ligne de commande n'en dépend pas (voir launchQueuesEnv).
q, qNotes := launchQueuesEnv(cfg, place, si)
if q != "" {
env["CUDA_SCALE_LAUNCH_QUEUES"] = q
}
notes = append(notes, qNotes...)
// Réglages d'expert passés par variable (voir backend_serve_expert.go) :
// absents du preset, ils ne posent rien.
g, gNotes := graphOptEnv(cfg, place, si)
if g != "" {
env["GGML_CUDA_GRAPH_OPT"] = g
}
o, oNotes := opOffloadMinBatchEnv(cfg, si)
if o != "" {
env["GGML_OP_OFFLOAD_MIN_BATCH"] = o
}
notes = append(append(notes, gNotes...), oNotes...)
// Threads : vide ou 0 = AUCUN drapeau, pour que llama.cpp prenne ses cœurs
// physiques au lieu de tous les threads logiques (voir threadArgs).
threads, threadNotes := threadArgs(cfg["THREADS"], cfg["THREADS_BATCH"], extra, si.CPU)
notes = append(notes, threadNotes...)
// Second slot (SIDE_SLOT, voir backend_serve_side.go) : sans la clé, sideCtx
// vaut 0 et rien de ce qui suit ne change.
sideCtx, sideMiB, sideWhy := sideSlotPlan(cfg, extra, si)
ctxArg := get("CTX", "32768")
if sideCtx > 0 {
ctxArg = strconv.Itoa(2 * sideCtx)
}
args = []string{bin,
"-m", si.Model,
"-c", ctxArg,
}
args = append(args, threads...)
args = append(args,
"-b", get("BATCH", "2048"),
"-ub", get("UBATCH", "512"),
"--host", get("HOST", "0.0.0.0"),
"--port", get("PORT", "8080"),
}
)
// --parallel 1 EXPLICITE : les llama-server récents ouvrent plusieurs slots
// par défaut, soit des tampons de calcul GPU multipliés d'autant — pour un
// serveur mono-utilisateur comme Loki, c'est de la VRAM brûlée pour rien.
@@ -272,8 +657,15 @@ func cmdServe(args []string) error {
// uniquement à cause de ce nouveau défaut. PARALLEL=n dans le preset pour qui
// veut vraiment servir plusieurs requêtes à la fois — ou --parallel dans
// EXTRA_ARGS, auquel cas on ne redouble pas le drapeau.
if !hasAnyFlag(extra, "--parallel", "-np") {
llmArgs = append(llmArgs, "--parallel", get("PARALLEL", "1"))
if sideCtx > 0 {
// Deux flux KV séparés de sideCtx jetons : aucun slot ne peut manger la
// place de l'autre (voir backend_serve_side.go).
args = append(args, "--parallel", "2", "--no-kv-unified")
} else if !hasAnyFlag(extra, "--parallel", "-np") {
args = append(args, "--parallel", get("PARALLEL", "1"))
}
if n := sideSlotNote(sideCtx, sideMiB, sideWhy, nglForced(cfg, extra)); n != "" {
notes = append(notes, n)
}
// Couches GPU. Quatre cas, dans cet ordre :
//
@@ -295,35 +687,29 @@ func cmdServe(args []string) error {
// ci-dessus. Sur un moteur qui connaît « auto », 999 devient donc auto, et on
// le DIT sur stderr plutôt que de le faire en douce. Qui veut réellement
// forcer tout sur le GPU écrit NGL=all (ou un nombre qui n'est pas 999).
if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
args, note := nglArgs(get("NGL", ""), binFitsLayersItself(bin))
if splitOn {
args = append(args, splitNGLArgs...) // « -ngl all », ou rien si EXTRA_ARGS l'a déjà
} else if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
ngl, note := nglArgs(get("NGL", ""), helpFitsLayersItself(si.Help))
if note != "" {
fmt.Fprintln(os.Stderr, "[loki serve] "+note)
notes = append(notes, note)
}
llmArgs = append(llmArgs, args...)
args = append(args, ngl...)
}
// Marge VRAM par carte du placement automatique (FIT_TARGET, voir
// fitTargetArgs) : seulement si --fit tournera vraiment.
fitt, fitNotes := fitTargetArgs(cfg, place, si)
args = append(append(args, fitt...), splitA...)
notes = append(notes, fitNotes...)
if ktv != "" {
llmArgs = append(llmArgs, "-ctk", ktv)
args = append(args, "-ctk", ktv)
}
if vtv != "" {
llmArgs = append(llmArgs, "-ctv", vtv)
args = append(args, "-ctv", vtv)
}
// Vision : le projecteur multimodal (mmproj-*.gguf) donne des yeux au modèle.
// C'est un fichier .gguf À PART du modèle, chargé via --mmproj. On le résout
// comme le modèle (nom simple cherché dans les dossiers déclarés, ou chemin
// absolu), pour qu'un preset écrit sous Windows reste lançable ailleurs et que
// le champ « Vision » de l'interface n'ait qu'à écrire le nom du fichier.
// Introuvable = on préfère le dire clairement plutôt que laisser llama-server
// mourir en boucle sur un « failed to load mmproj » cryptique.
if mm := strings.TrimSpace(cfg["MMPROJ"]); mm != "" {
mmPath, err := resolveServeModelPath(mm)
if err != nil {
return fmt.Errorf("projecteur vision introuvable : %s (%v)", mm, err)
}
if _, err := os.Stat(mmPath); err != nil {
return fmt.Errorf("projecteur vision introuvable : %s", mmPath)
}
llmArgs = append(llmArgs, "--mmproj", mmPath)
// Vision : chemin déjà résolu et vérifié par cmdServe (voir là-bas).
if si.MMProj != "" {
args = append(args, "--mmproj", si.MMProj)
}
// Raisonnement. Trois cas, et la nuance compte :
//
@@ -343,37 +729,124 @@ func cmdServe(args []string) error {
// contenu). L'anti-boucle côté llm_client.go reste le garde-fou. NE PAS forcer 0 :
// sur llama.cpp vanilla, 0 = "immediate end" → coupe tout le raisonnement
// (le fork ik_llama.cpp l'ignore). Configurable via REASONING_BUDGET.
llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1"))
} else if binSupportsReasoningFlag(bin) {
args = append(args, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1"))
} else if helpSupportsReasoningFlag(si.Help) {
// Pas de budget ici : « off » suffit, et un budget sur un moteur qui
// n'attend rien d'autre ne ferait qu'ajouter une occasion d'échouer.
llmArgs = append(llmArgs, "--reasoning", "off")
args = append(args, "--reasoning", "off")
} else {
// Vieux moteur (ou fork) qui ne connaît pas le drapeau : le lui passer
// le ferait sortir en erreur au démarrage, donc boucler. On le dit et on
// continue sans — mieux vaut un modèle qui réfléchit qu'un moteur mort.
fmt.Fprintf(os.Stderr, "[loki serve] ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement\n")
notes = append(notes, "ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement")
}
}
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
if k, _ := effectiveAPIKeyErr(); k != "" {
llmArgs = append(llmArgs, "--api-key", k)
// Conservation du raisonnement des tours passés : seulement si
// REASONING_PRESERVE est posée (voir reasoningPreserveArgs pour le compromis).
rp, rpNotes := reasoningPreserveArgs(cfg, extra, si)
args = append(args, rp...)
notes = append(notes, rpNotes...)
if si.APIKey != "" {
args = append(args, "--api-key", si.APIKey)
}
// Cache de prompts en RAM et dossier des slots (voir backend_serve_cache.go) :
// avant EXTRA_ARGS, qui garde le dernier mot.
cram, cramNotes := cacheRAMArgs(cfg, extra, si)
args = append(append(args, cram...), slotSaveArgs(cfg, extra, si)...)
notes = append(notes, cramNotes...)
if n := slotPersistNote(slotPersistPlan(cfg, extra, si)); n != "" {
notes = append(notes, n)
}
// Points de reprise des hybrides (voir backend_serve_ckpt.go).
ck, ckNotes := ckptArgs(cfg, extra, si)
args = append(args, ck...)
notes = append(notes, ckNotes...)
// Décodage spéculatif (voir backend_serve_spec.go) : rien tant que SPEC
// n'est pas posé.
sp, spNotes, _ := specArgs(cfg, extra, si)
args = append(args, sp...)
notes = append(notes, spNotes...)
// EXTRA_ARGS (déjà découpé comme le ferait le shell — les guillemets gardent
// ensemble un chemin qui contient des espaces) ferme la marche.
args = append(args, extra...)
// Garde-fous de fidélité (voir backend_serve_fidelity.go) : ils ne touchent à
// aucun drapeau, ils DISENT ce que la ligne finale change aux calculs.
ek, ev, src := effectiveKVTypes(cfg, extra, si.ArgEnv)
if n := kvFidelityNote(ek, ev, src, extra, si.ArgEnv); n != "" {
notes = append(notes, n)
}
notes = append(notes, lossyCacheNotes(extra, si)...)
// Experts MoE et mode de chargement (voir backend_serve_moe.go) : des notes
// seulement, la ligne ne bouge pas. Le refus d'un chargement voué à l'échec
// se décide dans cmdServe, sur cette même ligne.
notes = append(notes, moeNotes(cfg, extra, si)...)
if w, _ := loadModeRisk(cfg, args, si); w != "" {
notes = append(notes, w)
}
return args, env, notes
}
// argValue renvoie la valeur de la DERNIÈRE occurrence de flag (llama-server
// retient la dernière : EXTRA_ARGS peut surcharger --port).
func argValue(args []string, flag string) string {
v := ""
for i := 0; i+1 < len(args); i++ {
if args[i] == flag {
v = args[i+1]
}
}
return v
}
// waitPortFree vérifie que personne n'écoute déjà sur host:port. On tente une
// connexion TCP (fiable même si l'occupant a bindé en SO_REUSEADDR, où un
// Listen de test pourrait réussir). Un moteur qu'on vient d'arrêter peut tenir
// le port quelques instants : on réessaie jusqu'à wait avant de conclure.
// Repris d'AJEAN 0.15.9.
func waitPortFree(host, port string, wait time.Duration) error {
if port == "" {
return nil
}
h := strings.Trim(host, "[]")
if h == "" || h == "0.0.0.0" || h == "::" {
h = "127.0.0.1"
}
addr := net.JoinHostPort(h, port)
deadline := time.Now().Add(wait)
for {
c, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
if err != nil {
return nil
}
c.Close()
if time.Now().After(deadline) {
return fmt.Errorf("le port %s est déjà utilisé (sans doute un ancien llama-server encore actif) : "+
"« loki stop », docker restart loki, ou change PORT", port)
}
time.Sleep(500 * time.Millisecond)
}
}
// fastKV : combinaisons K/V que llama.cpp CUDA accélère en Flash-Attention
// avec ses options de compilation par défaut (GGML_CUDA_FA_ALL_QUANTS=OFF) —
// c'est le cas du moteur de l'image server-cuda comme de ceux tirés par OCI.
// Les autres (q5_*, q4_1, combinaisons mixtes) marchent mais sont reconverties
// en f16 à chaque pas : la génération ralentit nettement (AJEAN 0.15.9).
var fastKV = map[string]bool{"f16|f16": true, "q8_0|q8_0": true, "q4_0|q4_0": true, "bf16|bf16": true}
func slowKV(k, v string) bool {
if k == "" {
k = "f16"
}
if v == "" {
v = "f16"
}
return !fastKV[k+"|"+v]
}
func warnSlowKV(k, v string) {
if slowKV(k, v) {
fmt.Fprintf(os.Stderr, "[loki serve] avertissement : cache KV %s/%s non accéléré par llama.cpp CUDA "+
"(converti en f16 à chaque pas, lent). Préfère q8_0/q8_0, q4_0/q4_0 ou f16.\n", k, v)
}
// EXTRA_ARGS (déjà découpé plus haut comme le ferait le shell — les guillemets
// gardent ensemble un chemin qui contient des espaces) ferme la marche.
llmArgs = append(llmArgs, extra...)
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
// mmproj-F16.gguf) still resolve.
_ = os.Chdir(LokiHome())
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
bin, filepath.Base(model), get("PORT", "8080"))
// Hand off to the llama-server process. On Unix this replaces the current
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
return execServer(bin, llmArgs)
}
+299
View File
@@ -0,0 +1,299 @@
package loki
import (
"reflect"
"testing"
)
// Deux aides de moteur fabriquées, réduites aux lignes que lisent les tests de
// capacité : un llama.cpp récent (--load-mode, « -ngl auto », --reasoning) et
// un ancien qui ne connaît que les drapeaux d'origine.
const (
helpRecent = `-ngl, --gpu-layers, --n-gpu-layers N max. number of layers to store in VRAM, either an exact number,
'auto', or 'all' (default: auto)
-lm, --load-mode MODE how to load the model: auto, mmap, mmap+mlock, mlock, none, dio
-rea, --reasoning [on|off|auto] use reasoning/thinking in the chat (default: auto)
`
helpOld = `-ngl, --gpu-layers, --n-gpu-layers N number of layers to store in VRAM
--mlock force system to keep model in RAM
--no-mmap do not memory-map model
`
)
// TestBuildServeArgs fige la ligne de commande produite pour les presets
// courants : la moindre différence d'ordre ou de valeur casse le test. Le
// moteur retient la DERNIÈRE occurrence d'un drapeau, donc l'ordre compte
// autant que les valeurs — c'est la base sur laquelle les réglages suivants
// viendront s'ajouter, chacun avec sa ligne ici.
func TestBuildServeArgs(t *testing.T) {
const bin, model = "/opt/llama/llama-server", "/models/Qwen3.6-27B-Q4_K_M.gguf"
base := []string{bin, "-m", model,
"-c", "32768", "-b", "2048", "-ub", "512",
"--host", "0.0.0.0", "--port", "8080"}
with := func(tail ...string) []string {
return append(append([]string{}, base...), tail...)
}
// withThreads insère -t / -tb à leur place, juste après -c.
withThreads := func(threads []string, tail ...string) []string {
out := append(append([]string{}, base[:5]...), threads...)
return append(append(out, base[5:]...), tail...)
}
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want []string
wantEnv map[string]string
wantNotes int
}{
{
name: "dense par défaut, moteur récent : 999 devient auto, et on le dit",
cfg: map[string]string{"NGL": "999"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "auto"), wantNotes: 1,
},
{
name: "dense par défaut, moteur ancien : 999 reste 999",
cfg: map[string]string{"NGL": "999"},
si: serveSysInfo{Help: helpOld},
want: with("--parallel", "1", "-ngl", "999"),
},
{
name: "clé NGL absente, aide illisible : forme historique",
cfg: map[string]string{},
si: serveSysInfo{},
want: with("--parallel", "1", "-ngl", "999"),
},
{
name: "NGL forcé à un nombre : jamais touché",
cfg: map[string]string{"NGL": "28"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28"),
},
{
name: "NGL=auto : aucun drapeau",
cfg: map[string]string{"NGL": "auto"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1"),
},
{
name: "réglages du preset et cache KV commun",
cfg: map[string]string{"CTX": "65536", "THREADS": "8", "THREADS_BATCH": "16", "BATCH": "4096",
"UBATCH": "1024", "HOST": "127.0.0.1", "PORT": "9090", "PARALLEL": "2", "NGL": "all",
"KV_TYPE": "q8_0"},
si: serveSysInfo{Help: helpRecent},
want: []string{bin, "-m", model,
"-c", "65536", "-t", "8", "-tb", "16", "-b", "4096", "-ub", "1024",
"--host", "127.0.0.1", "--port", "9090",
"--parallel", "2", "-ngl", "all", "-ctk", "q8_0", "-ctv", "q8_0"},
wantNotes: 1, // cache quantifié : dit, jamais retiré
},
{
name: "THREADS=0 et THREADS_BATCH=0 : aucun drapeau, llama.cpp prend ses cœurs physiques",
cfg: map[string]string{"NGL": "28", "THREADS": "0", "THREADS_BATCH": "0"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28"),
},
{
name: "THREADS=6 seul : -t 6, -tb laissé au moteur (il recopie -t)",
cfg: map[string]string{"NGL": "28", "THREADS": "6"},
si: serveSysInfo{Help: helpRecent},
want: withThreads([]string{"-t", "6"}, "--parallel", "1", "-ngl", "28"),
},
{
name: "THREADS_BATCH seul : -tb sans -t",
cfg: map[string]string{"NGL": "28", "THREADS_BATCH": "12"},
si: serveSysInfo{Help: helpRecent},
want: withThreads([]string{"-tb", "12"}, "--parallel", "1", "-ngl", "28"),
},
{
name: "THREADS illisible ou négatif : ignoré, et dit",
cfg: map[string]string{"NGL": "28", "THREADS": "auto", "THREADS_BATCH": "-1"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28"), wantNotes: 2,
},
{
name: "-t=6 dans EXTRA_ARGS : pas de -t de Loki",
cfg: map[string]string{"NGL": "28", "THREADS": "8", "THREADS_BATCH": "16", "EXTRA_ARGS": "-t=6 --threads-batch 10"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "-t=6", "--threads-batch", "10"),
},
{
// Sans -tb, llama.cpp remplacerait tout le réglage CPU du batch par
// celui de -t : le --poll-batch de l'utilisateur serait effacé.
name: "--poll-batch dans EXTRA_ARGS, THREADS vide : -tb 0 le protège, et on le dit",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--poll-batch 0"},
si: serveSysInfo{Help: helpRecent},
want: withThreads([]string{"-tb", "0"}, "--parallel", "1", "-ngl", "28", "--poll-batch", "0"), wantNotes: 1,
},
{
name: "-Cb dans EXTRA_ARGS, THREADS=6 : -tb recopie 6",
cfg: map[string]string{"NGL": "28", "THREADS": "6", "EXTRA_ARGS": "-Cb 0xff"},
si: serveSysInfo{Help: helpRecent},
want: withThreads([]string{"-t", "6", "-tb", "6"}, "--parallel", "1", "-ngl", "28", "-Cb", "0xff"),
},
{
name: "-t=5 et --prio-batch dans EXTRA_ARGS : -tb recopie 5",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-t=5 --prio-batch 2"},
si: serveSysInfo{Help: helpRecent},
want: withThreads([]string{"-tb", "5"}, "--parallel", "1", "-ngl", "28", "-t=5", "--prio-batch", "2"),
},
{
name: "-Crb et -tb dans EXTRA_ARGS : Loki se tait",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-Crb 0-7 -tb 8"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "-Crb", "0-7", "-tb", "8"),
},
{
name: "conteneur à l'étroit : -t de la sonde, et une note",
cfg: map[string]string{"NGL": "28"},
si: serveSysInfo{Help: helpRecent, CPU: cpuBudget{N: 4, Engine: 8, Why: "quota cgroup de 4 CPU"}},
want: withThreads([]string{"-t", "4"}, "--parallel", "1", "-ngl", "28"), wantNotes: 1,
},
{
name: "THREADS explicite : la sonde se tait",
cfg: map[string]string{"NGL": "28", "THREADS": "6"},
si: serveSysInfo{Help: helpRecent, CPU: cpuBudget{N: 4, Engine: 8, Why: "quota"}},
want: withThreads([]string{"-t", "6"}, "--parallel", "1", "-ngl", "28"),
},
{
name: "masque d'affinité dans EXTRA_ARGS : la sonde se tait",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-Cr 0-3"},
si: serveSysInfo{Help: helpRecent, CPU: cpuBudget{N: 4, Engine: 8, Why: "quota"}},
want: with("--parallel", "1", "-ngl", "28", "-Cr", "0-3"),
},
{
name: "cache KV séparé K/V",
cfg: map[string]string{"NGL": "28", "KV_TYPE": "q8_0", "KV_TYPE_V": "q4_0"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "-ctk", "q8_0", "-ctv", "q4_0"), wantNotes: 1,
},
{
// Preset MoE typique : experts sur CPU via EXTRA_ARGS, qui pose aussi
// --parallel, -ngl et le cache KV. Loki n'ajoute alors ni --parallel ni
// -ngl, et EXTRA_ARGS ferme la marche (la dernière occurrence gagne).
name: "MoE : EXTRA_ARGS avec -ot, --n-cpu-moe, -ctk q8_0",
cfg: map[string]string{"NGL": "999", "PARALLEL": "1",
"EXTRA_ARGS": `-ngl 99 --parallel 1 -ot "blk\.(\d+)\.ffn_.*_exps=CPU" --n-cpu-moe 30 -ctk q8_0 -ctv q8_0 -fa on`},
si: serveSysInfo{Help: helpRecent},
want: with("-ngl", "99", "--parallel", "1", "-ot", `blk\.(\d+)\.ffn_.*_exps=CPU`,
"--n-cpu-moe", "30", "-ctk", "q8_0", "-ctv", "q8_0", "-fa", "on"),
wantNotes: 1, // q8_0 venu d'EXTRA_ARGS : vu comme celui de KV_TYPE
},
{
name: "--n-gpu-layers=N dans EXTRA_ARGS : pas de -ngl en double",
cfg: map[string]string{"NGL": "999", "EXTRA_ARGS": "--n-gpu-layers=40 -np 2"},
si: serveSysInfo{Help: helpRecent},
want: with("--n-gpu-layers=40", "-np", "2"),
},
{
name: "raisonnement actif : budget illimité par défaut",
cfg: map[string]string{"NGL": "28", "REASONING": "on"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "--reasoning", "on", "--reasoning-budget", "-1"),
},
{
name: "raisonnement auto avec budget choisi",
cfg: map[string]string{"NGL": "28", "REASONING": " auto ", "REASONING_BUDGET": "4096"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "--reasoning", "auto", "--reasoning-budget", "4096"),
},
{
name: "raisonnement interdit, moteur qui connaît le drapeau",
cfg: map[string]string{"NGL": "28", "REASONING": "off"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "--reasoning", "off"),
},
{
name: "raisonnement interdit, vieux moteur : aucun drapeau, une note",
cfg: map[string]string{"NGL": "28", "REASONING": "off"},
si: serveSysInfo{Help: helpOld},
want: with("--parallel", "1", "-ngl", "28"), wantNotes: 1,
},
{
name: "vision et clé d'API, avant EXTRA_ARGS",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-fa on"},
si: serveSysInfo{Help: helpRecent, MMProj: "/models/mmproj-F16.gguf", APIKey: "secret"},
want: with("--parallel", "1", "-ngl", "28", "--mmproj", "/models/mmproj-F16.gguf",
"--api-key", "secret", "-fa", "on"),
},
{
name: "drapeaux de chargement anciens → --load-mode sur moteur récent",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--mlock --no-mmap -fa on"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "-fa", "on", "--load-mode", "mlock"),
},
{
name: "--load-mode retraduit pour un moteur ancien",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--load-mode none"},
si: serveSysInfo{Help: helpOld},
want: with("--parallel", "1", "-ngl", "28", "--no-mmap"),
},
{
// dio n'existe pas sur l'ancien moteur : retiré, et dit par une note
// RENDUE (buildServeArgs n'écrit rien elle-même).
name: "--load-mode dio sur moteur ancien : retiré, une note",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--load-mode dio -fa on"},
si: serveSysInfo{Help: helpOld},
want: with("--parallel", "1", "-ngl", "28", "-fa", "on"), wantNotes: 1,
},
{
name: "sélection GPU : variables d'environnement, ligne inchangée",
cfg: map[string]string{"NGL": "28", "CUDA_VISIBLE_DEVICES": "1,0"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28"),
wantEnv: map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "CUDA_DEVICE_ORDER": "PCI_BUS_ID"},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
c.si.Model = model
args, env, notes := buildServeArgs(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), bin, c.si)
if !reflect.DeepEqual(args, c.want) {
t.Fatalf("args\n got %q\nwant %q", args, c.want)
}
wantEnv := c.wantEnv
if wantEnv == nil {
wantEnv = map[string]string{}
}
if !reflect.DeepEqual(env, wantEnv) {
t.Fatalf("env = %v, want %v", env, wantEnv)
}
if len(notes) != c.wantNotes {
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
}
})
}
}
func TestHelpCapabilities(t *testing.T) {
if !helpSupportsReasoningFlag(helpRecent) || helpSupportsReasoningFlag(helpOld) {
t.Fatal("--reasoning mal détecté")
}
if !helpSupportsLoadMode(helpRecent) || helpSupportsLoadMode(helpOld) {
t.Fatal("--load-mode mal détecté")
}
if !helpSupportsNGLAuto(helpRecent) || helpSupportsNGLAuto(helpOld) {
t.Fatal("« -ngl auto » mal détecté")
}
// Aide illisible = aucune capacité : on garde la forme historique.
if helpFitsLayersItself("") || helpSupportsReasoningFlag("") {
t.Fatal("une aide vide ne doit promettre aucune capacité")
}
}
func TestServeKVTypes(t *testing.T) {
for _, c := range []struct {
cfg map[string]string
k, v string
}{
{map[string]string{}, "", ""},
{map[string]string{"KV_TYPE": "q8_0"}, "q8_0", "q8_0"},
{map[string]string{"KV_TYPE": "q8_0", "KV_TYPE_K": "f16"}, "f16", "q8_0"},
{map[string]string{"KV_TYPE_V": "q4_0"}, "", "q4_0"},
} {
if k, v := serveKVTypes(c.cfg); k != c.k || v != c.v {
t.Errorf("%v : got %s/%s, want %s/%s", c.cfg, k, v, c.k, c.v)
}
}
}
+576
View File
@@ -0,0 +1,576 @@
package loki
import (
"context"
"fmt"
"io/fs"
"math"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
// Cache de prompts en RAM (--cache-ram) et isolation des travaux annexes.
//
// llama-server garde en RAM hôte une copie EXACTE des états de slot qu'il quitte
// (cache KV, état récurrent, points de reprise, contexte du brouillon MTP) et la
// recharge octet pour octet quand la conversation revient. Ce n'est pas une
// approximation : la seule différence avec un prefill complet est le bruit de
// virgule flottante dû à un découpage des lots différent, exactement comme le
// cache_prompt par défaut. Le type du cache KV n'est pas touché, il est copié
// tel quel.
//
// Le défaut du moteur (8 Gio) est trop petit pour une conversation longue d'un
// modèle dense : l'état principal (30 à 65 k jetons) ne tient plus à côté de
// celui d'un vérificateur ou d'un sous-agent, et la conversation est alors
// recalculée en entier — 30 à 80 s sur un 27B. Loki agrandit donc ce cache,
// mais seulement quand il sait mesurer le besoin et que la RAM est libre : il ne
// descend JAMAIS sous le défaut du moteur, et se tait dès que l'utilisateur a
// tranché (CACHE_RAM, -cram dans EXTRA_ARGS, LLAMA_ARG_CACHE_RAM).
//
// Tout ce qui décide est pur ; probeCacheRAM fait les lectures (GGUF, RAM,
// nvidia-smi) une fois, avant buildServeArgs.
// engineCacheRAMDefault : cache_ram_mib par défaut de llama.cpp (common.h).
const engineCacheRAMDefault = 8192
// cacheArgEnv : la variable qui règle le cache sans drapeau. llama.cpp donne la
// priorité à la ligne de commande : un --cache-ram de Loki l'écraserait.
var cacheArgEnv = []string{"LLAMA_ARG_CACHE_RAM"}
// probeCacheRAM complète si avec ce dont cacheRAMAuto a besoin. Chaque lecture
// ratée laisse un zéro, et un zéro veut dire « on ne sait pas » : le moteur
// garde alors son défaut.
func probeCacheRAM(cfg map[string]string, extra []string, si *serveSysInfo) {
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range cacheArgEnv {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
if si.Model != "" {
si.ModelBytes = shardFamilySize(filepath.Dir(si.Model), filepath.Base(si.Model))
if g, err := ggufMeta(si.Model); err == nil {
si.GGUF = &g
}
}
used, total := ramUsageMB()
if total > 0 {
si.RAMMiB, si.RAMAvailMiB = int64(total), int64(total-used)
}
if runtime.GOOS == "linux" {
if limit, cur := cgroupMemMiB(os.DirFS("/")); limit > 0 {
if si.RAMMiB == 0 || limit < si.RAMMiB {
si.RAMMiB = limit
}
if free := limit - cur; free >= 0 && (si.RAMAvailMiB == 0 || free < si.RAMAvailMiB) {
si.RAMAvailMiB = free
}
}
}
// Mémoire unifiée : Apple Silicon seulement. Un Mac Intel à carte AMD a une
// VRAM à lui, que Loki ne sait pas mesurer (loadModeRisk la tient pour
// inconnue, jamais pour nulle).
si.UnifiedMem = runtime.GOOS == "darwin" && runtime.GOARCH == "arm64"
// macOS, AMD, Vulkan, --device choisi à la main : pas de total VRAM fiable,
// donc pas d'agrandissement.
if runtime.GOOS == "darwin" || hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" {
return
}
// La sélection du preset d'abord (cudaDeviceEnv l'accompagne toujours de
// PCI_BUS_ID), celle de l'environnement ensuite : l'aperçu de l'interface
// tourne dans un process où le preset n'a rien posé.
cvd, pci := cfg["CUDA_VISIBLE_DEVICES"], true
if cvd == "" {
cvd, pci = os.Getenv("CUDA_VISIBLE_DEVICES"), os.Getenv("CUDA_DEVICE_ORDER") == "PCI_BUS_ID"
}
si.VRAMMiB = nvidiaVRAMMiB(cvd, pci, 3*time.Second)
}
// cgroupMemMiB : limite mémoire du conteneur et sa consommation, en Mio (v2,
// sinon v1). 0 = pas de limite. Le MemAvailable de /proc/meminfo voit l'hôte
// entier : sans ça, un conteneur limité à 16 Go sur une machine de 64 Go se
// croirait riche.
func cgroupMemMiB(fsys fs.FS) (limit, current int64) {
read := func(p string) int64 {
b, err := fs.ReadFile(fsys, p)
if err != nil {
return 0
}
n, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
if err != nil || n <= 0 || n >= 1<<60 { // « max » ou la valeur géante du v1 sans limite
return 0
}
return n >> 20
}
if limit = read("sys/fs/cgroup/memory.max"); limit > 0 {
return limit, read("sys/fs/cgroup/memory.current")
}
if limit = read("sys/fs/cgroup/memory/memory.limit_in_bytes"); limit > 0 {
return limit, read("sys/fs/cgroup/memory/memory.usage_in_bytes")
}
return 0, 0
}
// nvidiaVRAMMiB : VRAM totale des cartes que llama-server verra. Borné dans le
// temps comme nvidiaGPUCount ; au moindre doute, 0.
func nvidiaVRAMMiB(cvd string, pciOrder bool, timeout time.Duration) int64 {
out, ok := smiTotals.get(time.Now, timeout)
if !ok {
return 0
}
return vramFromSMI(out, cvd, pciOrder)
}
// nvidiaTotalsQuery : la lecture brute des mémoires totales. Variable pour
// les tests.
var nvidiaTotalsQuery = func(timeout time.Duration) (string, error) {
if !hasTool("nvidia-smi") {
return "", exec.ErrNotFound
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
out, err := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index,memory.total",
"--format=csv,noheader,nounits")).Output()
return string(out), err
}
// smiTotalsCache : la lecture des mémoires totales, partagée quelques
// secondes. L'éditeur de preset demande à l'ouverture la taille du cache de
// prompts (et celle du second slot) et les avis MoE, en parallèle : chacun
// lançait SON nvidia-smi pour une réponse identique — la mémoire totale d'une
// carte ne bouge pas. Le verrou tenu pendant la lecture fait attendre les
// appels simultanés sur la même ; la sélection des cartes (CUDA_VISIBLE_DEVICES)
// s'applique après, sur la sortie brute. Un échec est gardé aussi, le temps du
// délai : la même salve ne relance pas trois lectures qui échoueront.
type smiTotalsCache struct {
mu sync.Mutex
exp time.Time
out string
ok bool
}
const smiTotalsTTL = 10 * time.Second
var smiTotals smiTotalsCache
func (c *smiTotalsCache) get(now func() time.Time, timeout time.Duration) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if now().Before(c.exp) {
return c.out, c.ok
}
out, err := nvidiaTotalsQuery(timeout)
c.out, c.ok, c.exp = out, err == nil, now().Add(smiTotalsTTL)
return c.out, c.ok
}
// vramFromSMI additionne la VRAM des cartes sélectionnées par
// CUDA_VISIBLE_DEVICES. Les index de nvidia-smi sont dans l'ordre du bus PCI ;
// ceux de CUDA aussi, mais seulement avec CUDA_DEVICE_ORDER=PCI_BUS_ID. Sans
// lui, on ne sait pas quelle carte est laquelle : on prend la plus petite autant
// de fois qu'il y a de cartes — un minorant, qui ne fait que rendre Loki plus
// prudent. Un UUID ou une tranche MIG : on ne sait pas, 0.
func vramFromSMI(out, cvd string, pciOrder bool) int64 {
byIdx := map[int]int64{}
var minMiB int64
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
f := strings.Split(line, ",")
if len(f) < 2 {
continue
}
i, err1 := strconv.Atoi(strings.TrimSpace(f[0]))
m, err2 := strconv.ParseInt(strings.TrimSpace(f[1]), 10, 64)
if err1 != nil || err2 != nil || m <= 0 {
return 0
}
byIdx[i] = m
if minMiB == 0 || m < minMiB {
minMiB = m
}
}
if len(byIdx) == 0 {
return 0
}
cvd = strings.TrimSpace(cvd)
if cvd == "" {
var sum int64
for _, m := range byIdx {
sum += m
}
return sum
}
var sum int64
n := 0
for _, d := range strings.Split(cvd, ",") {
if d = strings.TrimSpace(d); d == "" {
continue
}
i, err := strconv.Atoi(d)
if err != nil {
return 0
}
m, ok := byIdx[i]
if !ok {
return 0
}
sum += m
n++
}
if !pciOrder {
return minMiB * int64(n)
}
return sum
}
// kvTypeBytes : octets par élément d'un type de cache (blocs de 32 pour les
// types quantifiés de ggml). Inconnu : compté comme f16, l'estimation reste
// bornée par le plafond de RAM.
func kvTypeBytes(t string) float64 {
switch strings.ToLower(strings.TrimSpace(t)) {
case "f32":
return 4
case "q8_0":
return 34.0 / 32
case "q5_1":
return 24.0 / 32
case "q5_0":
return 22.0 / 32
case "q4_1":
return 20.0 / 32
case "q4_0", "iq4_nl":
return 18.0 / 32
}
return 2
}
// ggufStateBytes estime ce que pèse l'état d'une séquence : octets de cache KV
// par jeton (couches d'attention), et octets d'état récurrent (couches SSM d'un
// hybride, en f32 comme dans llama.cpp). 0 par jeton = on ne sait pas.
func ggufStateBytes(g GGUFInfo, kt, vt string) (perToken float64, recurrent int64) {
kl, vl := g.KeyLen, g.ValLen
if g.EmbeddingLength > 0 && g.HeadCount > 0 {
if kl == 0 {
kl = g.EmbeddingLength / g.HeadCount
}
if vl == 0 {
vl = g.EmbeddingLength / g.HeadCount
}
}
if kl == 0 || vl == 0 || g.BlockCount == 0 {
return 0, 0
}
heads, recLayers := 0, 0
switch {
case len(g.HeadCountKVLayers) > 0:
for _, h := range g.HeadCountKVLayers {
heads += h
if h == 0 && g.Hybrid {
recLayers++
}
}
case g.Hybrid && g.FullAttnInterval > 0:
attn := g.BlockCount / g.FullAttnInterval
heads, recLayers = attn*g.HeadCountKV, g.BlockCount-attn
default:
heads = g.BlockCount * g.HeadCountKV
if heads == 0 && g.Hybrid {
recLayers = g.BlockCount // récurrent pur
}
}
perToken = float64(heads) * (float64(kl)*kvTypeBytes(kt) + float64(vl)*kvTypeBytes(vt))
if recLayers > 0 {
conv := int64(0)
if g.SSMConv > 1 {
conv = int64(g.SSMConv-1) * int64(g.SSMInner+2*g.SSMGroups*g.SSMState)
}
recurrent = int64(recLayers) * (conv + int64(g.SSMState)*int64(g.SSMInner)) * 4
}
return perToken, recurrent
}
// serveCtx : la taille de contexte que le moteur ouvrira (-c d'EXTRA_ARGS, sinon
// CTX, sinon le défaut de Loki ; 0 = celle d'entraînement du modèle).
func serveCtx(cfg map[string]string, extra []string, g *GGUFInfo) int {
s := flagValue(extra, "-c", "--ctx-size")
if s == "" {
s = strings.TrimSpace(cfg["CTX"])
}
if s == "" {
s = "32768"
}
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
return 0
}
if n == 0 && g != nil {
n = g.ContextLength
}
return n
}
// cpuWeights dit pourquoi des poids (ou le cache KV) vivent en RAM hôte. Le
// cache de prompts y ferait alors concurrence au cache de pages des experts
// mappés : on n'y touche pas. Vide = tout est sur GPU.
func cpuWeights(cfg map[string]string, extra []string, argEnv map[string]string, blocks int) string {
ot := flagValues(extra, "-ot", "--override-tensor")
if v := argEnv["LLAMA_ARG_OVERRIDE_TENSOR"]; v != "" {
ot = append(ot, v)
}
for _, v := range ot {
for _, el := range strings.Split(v, ",") {
if _, buft, ok := strings.Cut(el, "="); ok && strings.HasPrefix(strings.ToUpper(strings.TrimSpace(buft)), "CPU") {
return "tenseurs sur CPU (-ot)"
}
}
}
if hasAnyFlag(extra, "--cpu-moe", "-cmoe") || envTruthy(argEnv["LLAMA_ARG_CPU_MOE"]) {
return "experts sur CPU"
}
for _, n := range []struct{ env, short, long string }{
{"LLAMA_ARG_N_CPU_MOE", "-ncmoe", "--n-cpu-moe"},
{"LLAMA_ARG_N_CPU_FFN", "-ncffn", "--n-cpu-ffn"},
} {
for _, v := range append(flagValues(extra, n.short, n.long), argEnv[n.env]) {
if v = strings.TrimSpace(v); v != "" && v != "0" {
return "couches " + n.long + " sur CPU"
}
}
}
if !kvOffloaded(extra, argEnv) {
return "cache KV sur CPU"
}
ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
if ngl == "" {
ngl = strings.TrimSpace(cfg["NGL"])
if strings.EqualFold(ngl, "auto") {
ngl = argEnv["LLAMA_ARG_N_GPU_LAYERS"]
}
}
// 999 est la sentinelle « toutes » ; un nombre plus petit que le modèle (sa
// couche de sortie comprise) laisse des couches au CPU.
if n, err := strconv.Atoi(strings.TrimSpace(ngl)); err == nil && n != 999 && (blocks == 0 || n < blocks+1) {
return "couches GPU limitées à " + strconv.Itoa(n)
}
return ""
}
// cacheRAMAuto calcule la taille du cache de prompts quand CACHE_RAM est vide
// ou « auto ». 0 = garder le défaut du moteur, why dit pourquoi.
//
// Besoin : 2,5 fois l'état d'une conversation pleine (cache KV à CTX jetons,
// plus l'état récurrent et ses points de reprise sur un hybride) — de quoi
// garder la conversation principale ET l'état d'un travail annexe. Plafond : 30
// % de la RAM (limite du conteneur comprise) et la moitié de ce qui est libre au
// lancement. Ce plafond est un réglage, pas un filet : sous Linux, un
// dépassement finit en général par l'OOM killer, pas par le repli du moteur.
func cacheRAMAuto(cfg map[string]string, extra []string, si serveSysInfo) (mib int64, why string) {
switch {
case si.VRAMMiB <= 0:
return 0, "VRAM NVIDIA inconnue"
case si.GGUF == nil:
return 0, "métadonnées GGUF illisibles"
case si.RAMMiB <= 0 || si.RAMAvailMiB <= 0:
return 0, "RAM inconnue"
case si.ModelBytes <= 0:
return 0, "taille du modèle inconnue"
}
if r := cpuWeights(cfg, extra, si.ArgEnv, si.GGUF.BlockCount); r != "" {
return 0, r
}
kt, vt, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
perTok, rec := ggufStateBytes(*si.GGUF, kt, vt)
ctx := serveCtx(cfg, extra, si.GGUF)
if perTok <= 0 || ctx <= 0 {
return 0, "taille d'état inconnue"
}
kvBytes := perTok * float64(ctx)
if float64(si.ModelBytes)+kvBytes > 0.9*float64(si.VRAMMiB)*(1<<20) {
return 0, "modèle trop gros pour la VRAM : des poids restent en RAM"
}
state := kvBytes
if rec > 0 {
// Points de reprise : llama.cpp en garde jusqu'à 32 par séquence ; on en
// compte un par tranche de 8 k jetons, estimation grossière mais bornée.
state += float64(rec) * float64(min(32, ctx/8192+1)+1)
}
need := int64(math.Ceil(2.5 * state / (1 << 20)))
ceiling := min(si.RAMMiB*30/100, si.RAMAvailMiB/2)
if ceiling <= engineCacheRAMDefault {
return 0, fmt.Sprintf("RAM libre trop juste (plafond %d Mio)", ceiling)
}
if need <= engineCacheRAMDefault {
return 0, fmt.Sprintf("le défaut suffit (besoin estimé %d Mio)", need)
}
return min(need, ceiling), fmt.Sprintf("besoin estimé %d Mio, plafond %d Mio", need, ceiling)
}
// cacheRAMSetting lit CACHE_RAM : auto (vide ou « auto »), ou un nombre de Mio
// passé tel quel (-1 = sans limite, 0 = cache coupé). ok=false : valeur
// illisible.
func cacheRAMSetting(cfg map[string]string) (manual string, auto, ok bool) {
v := strings.TrimSpace(cfg["CACHE_RAM"])
if v == "" || strings.EqualFold(v, "auto") {
return "", true, true
}
if n, err := strconv.Atoi(v); err == nil && n >= -1 {
return strconv.Itoa(n), false, true
}
return "", false, false
}
// cacheRAMArgs : le --cache-ram que Loki ajoute, et ce qu'il en dit.
func cacheRAMArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
manual, auto, ok := cacheRAMSetting(cfg)
if !ok {
return nil, []string{"CACHE_RAM=" + cfg["CACHE_RAM"] + " illisible (un nombre de Mio, -1, 0 ou auto) : ignoré"}
}
if !strings.Contains(si.Help, "--cache-ram") {
if !auto {
notes = append(notes, "ce moteur ne connaît pas --cache-ram : CACHE_RAM ignoré")
}
return nil, notes
}
if hasAnyFlag(extra, "-cram", "--cache-ram") {
return nil, nil
}
if si.ArgEnv["LLAMA_ARG_CACHE_RAM"] != "" {
if !auto {
notes = append(notes, "LLAMA_ARG_CACHE_RAM est posée : CACHE_RAM ignoré")
}
return nil, notes
}
if !auto {
return []string{"--cache-ram", manual}, nil
}
n, why := cacheRAMAuto(cfg, extra, si)
if n <= 0 {
return nil, nil
}
return []string{"--cache-ram", strconv.FormatInt(n, 10)},
[]string{fmt.Sprintf("cache de prompts : --cache-ram %d (RAM hôte, copie exacte de l'état, jamais la VRAM ; %s). "+
"CACHE_RAM=<Mio> pour fixer, 0 pour couper.", n, why)}
}
// cacheRAMOff : le cache de prompts est-il coupé (0) par l'une des trois
// sources ? Sans lui, effacer un slot ne protège rien.
func cacheRAMOff(cfg map[string]string, extra []string, argEnv map[string]string) bool {
v := flagValue(extra, "-cram", "--cache-ram")
if v == "" {
v = argEnv["LLAMA_ARG_CACHE_RAM"]
}
if v == "" {
v, _, _ = cacheRAMSetting(cfg)
}
return strings.TrimSpace(v) == "0"
}
// cacheIsolationOn : CACHE_ISOLATE=off coupe l'effacement du slot après les
// travaux annexes (et donc --slot-save-path), sans toucher au code.
func cacheIsolationOn(cfg map[string]string) bool {
return !strings.EqualFold(strings.TrimSpace(cfg["CACHE_ISOLATE"]), "off")
}
// loopbackHost : le moteur n'écoute que sur la machine.
func loopbackHost(h string) bool {
switch strings.Trim(strings.TrimSpace(h), "[]") {
case "127.0.0.1", "::1", "localhost":
return true
}
return false
}
// slotSaveArgs : --slot-save-path, sans lequel llama-server refuse toute action
// sur /slots — l'effacement compris. Mais le drapeau ouvre aussi save et
// restore, qui écrivent des Gio sur le disque, à quiconque joint le moteur. On
// ne le pose donc que si le moteur est fermé aux autres (boucle locale, ou clé
// d'API exigée), que le dossier existe (le moteur refuse de démarrer sinon), et
// qu'il servira : à l'isolation (cache actif, CACHE_ISOLATE pas à off), ou à
// SLOT_PERSIST accepté (backend_serve_persist.go). Les proxys de Loki refusent
// de leur côté tout /slots qui n'est pas une lecture.
func slotSaveArgs(cfg map[string]string, extra []string, si serveSysInfo) []string {
if si.SlotDir == "" || !strings.Contains(si.Help, "--slot-save-path") || hasAnyFlag(extra, "--slot-save-path") {
return nil
}
persist, _ := slotPersistPlan(cfg, extra, si)
if !persist && (!cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv)) {
return nil
}
if !slotSaveSafe(cfg, extra, si) {
return nil
}
return []string{"--slot-save-path", si.SlotDir}
}
// prepareSlotDir crée LOKI_HOME/slots (0700) et le vide : un fichier présent ne
// peut venir que d'un appel qui n'aurait pas dû passer. Seule exception, avec
// keepPersist (SLOT_PERSIST demandé) : les états que Loki y a lui-même gardés
// à la bascule de preset, au nom reconnaissable. Vide = dossier indisponible,
// le drapeau ne sera pas posé.
func prepareSlotDir(home string, keepPersist bool) string {
dir := filepath.Join(home, "slots")
if err := os.MkdirAll(dir, 0o700); err != nil {
return ""
}
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
return ""
}
if entries, err := os.ReadDir(dir); err == nil {
for _, e := range entries {
if e.Type().IsRegular() && !(keepPersist && slotPersistFileRe.MatchString(e.Name())) {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
}
return dir
}
// cacheRAMPreview : la taille que le moteur prendra pour ce preset, pour
// l'éditeur. 0 = défaut du moteur (8192 Mio). Même calcul qu'au lancement, mais
// sans l'aide du moteur : l'aperçu dit ce que Loki demanderait.
func cacheRAMPreview(content string) (mib int64, why string) {
cfg := parseEnv(content)
if isExternalConfig(cfg) {
return 0, "preset externe"
}
extra := splitArgs(cfg["EXTRA_ARGS"])
if v := flagValue(extra, "-cram", "--cache-ram"); v != "" {
n, _ := strconv.ParseInt(v, 10, 64)
return n, "fixé par EXTRA_ARGS"
}
if v := strings.TrimSpace(os.Getenv("LLAMA_ARG_CACHE_RAM")); v != "" {
n, _ := strconv.ParseInt(v, 10, 64)
return n, "fixé par LLAMA_ARG_CACHE_RAM"
}
manual, auto, ok := cacheRAMSetting(cfg)
if !ok {
return 0, "CACHE_RAM illisible"
}
if !auto {
n, _ := strconv.ParseInt(manual, 10, 64)
return n, "fixé par CACHE_RAM"
}
si := serveSysInfo{ArgEnv: map[string]string{}}
for _, k := range append(append(append([]string{}, serveArgEnv...), fidelityArgEnv...), cacheArgEnv...) {
if v, ok := os.LookupEnv(k); ok && v != "" {
si.ArgEnv[k] = v
}
}
if m := strings.TrimSpace(cfg["MODEL"]); m != "" {
if p, err := resolveServeModelPath(m); err == nil {
si.Model = p
}
}
if si.Model == "" {
return 0, "modèle introuvable"
}
probeCacheRAM(cfg, extra, &si)
return cacheRAMAuto(cfg, extra, si)
}
+319
View File
@@ -0,0 +1,319 @@
package loki
import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"testing/fstest"
"time"
)
const helpCacheRAM = helpRecent + `-cram, --cache-ram N set the maximum cache size in MiB (default: 8192, -1 - no limit, 0 - disable)
--slot-save-path PATH path to save slot kv cache (default: disabled)
`
// denseGPU : un 27B dense de 17 Go sur deux cartes de 24 Go, machine de 64 Go.
// État : 64 couches × 4 têtes KV × (256+256) × 2 octets = 0,25 Mio par jeton.
func denseGPU() serveSysInfo {
return serveSysInfo{
Help: helpCacheRAM,
Model: "/models/dense.gguf",
GGUF: &GGUFInfo{Arch: "qwen3", BlockCount: 64, HeadCountKV: 4, KeyLen: 256, ValLen: 256,
ContextLength: 262144},
ModelBytes: 17 << 30,
RAMMiB: 65536, RAMAvailMiB: 60000,
VRAMMiB: 2 * 24576,
ArgEnv: map[string]string{},
}
}
func TestCacheRAMArgs(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
notes int
}{
{name: "dense sur GPU, 64 Go, 65k : besoin 40 Gio, plafonné à 30 % de la RAM",
cfg: map[string]string{"CTX": "65536"}, want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "contexte court : le défaut du moteur suffit",
cfg: map[string]string{"CTX": "8192"}},
{name: "cache q8_0 : l'état pèse deux fois moins, besoin sous le plafond",
cfg: map[string]string{"CTX": "32768", "KV_TYPE": "q8_0"}, want: []string{"--cache-ram", "10880"}, notes: 1},
{name: "même contexte en f16 : plafonné",
cfg: map[string]string{"CTX": "32768"}, want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "-c d'EXTRA_ARGS l'emporte sur CTX",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-c", "8192"}},
{name: "MoE, experts sur CPU par -ot : jamais touché (pas de 4096)",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "exps=CPU"}},
{name: "-ot mixte avec un élément sur CPU",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "a=CUDA0,b=CPU"}},
{name: "-ot vers une autre carte seulement : tout reste en VRAM",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "blk\\.6.*=CUDA1"},
want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "--n-cpu-moe",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"--n-cpu-moe", "30"}},
{name: "LLAMA_ARG_N_CPU_MOE",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_N_CPU_MOE"] = "20" }},
{name: "cache KV sur CPU",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-nkvo"}},
{name: "NGL plus petit que le modèle",
cfg: map[string]string{"CTX": "65536", "NGL": "40"}},
{name: "NGL=999 : toutes les couches",
cfg: map[string]string{"CTX": "65536", "NGL": "999"}, want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "modèle trop gros pour la VRAM",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ModelBytes = 40 << 30 }},
{name: "pas de VRAM connue (macOS, AMD, sans nvidia-smi)",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.VRAMMiB = 0 }},
{name: "GGUF illisible",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.GGUF = nil }},
{name: "RAM libre trop juste",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.RAMAvailMiB = 12000 }},
{name: "CACHE_RAM fixé : passé tel quel",
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "12000"}, want: []string{"--cache-ram", "12000"}},
{name: "CACHE_RAM=-1 : sans limite, passé tel quel",
cfg: map[string]string{"CACHE_RAM": "-1"}, want: []string{"--cache-ram", "-1"}},
{name: "CACHE_RAM=0 : cache coupé",
cfg: map[string]string{"CACHE_RAM": "0"}, want: []string{"--cache-ram", "0"}},
{name: "CACHE_RAM=auto",
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "auto"}, want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "CACHE_RAM illisible : ignoré, et dit",
cfg: map[string]string{"CACHE_RAM": "beaucoup"}, notes: 1},
{name: "-cram d'EXTRA_ARGS : Loki se tait",
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "12000"}, extra: []string{"-cram", "2048"}},
{name: "LLAMA_ARG_CACHE_RAM : la ligne de commande l'écraserait, Loki se tait",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CACHE_RAM"] = "4096" }},
{name: "LLAMA_ARG_CACHE_RAM et CACHE_RAM : la variable gagne, et on le dit",
cfg: map[string]string{"CACHE_RAM": "12000"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CACHE_RAM"] = "4096" }, notes: 1},
{name: "moteur sans --cache-ram : rien, même fixé",
cfg: map[string]string{"CACHE_RAM": "12000"}, si: func(s *serveSysInfo) { s.Help = helpRecent }, notes: 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := denseGPU()
if c.si != nil {
c.si(&si)
}
got, notes := cacheRAMArgs(c.cfg, c.extra, si)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("args = %v, attendu %v (notes %v)", got, c.want, notes)
}
if len(notes) != c.notes {
t.Fatalf("notes = %v, attendu %d", notes, c.notes)
}
})
}
}
// Hybride (Qwen3.5) : 16 couches d'attention sur 64, état récurrent des 48
// autres, compté avec ses points de reprise.
func TestCacheRAMAutoHybride(t *testing.T) {
si := denseGPU()
layers := make([]int, 64)
for i := range layers {
if (i+1)%4 == 0 {
layers[i] = 4
}
}
si.GGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 64, HeadCountKV: 4, HeadCountKVLayers: layers,
KeyLen: 256, ValLen: 256, FullAttnInterval: 4, Hybrid: true,
SSMConv: 4, SSMInner: 6144, SSMState: 128, SSMGroups: 16}
perTok, rec := ggufStateBytes(*si.GGUF, "", "")
if perTok != 65536 || rec != 156893184 {
t.Fatalf("état = %v o/jeton, %d o récurrents", perTok, rec)
}
// 4 Gio de KV + 10 états récurrents (9 points de reprise + l'état courant),
// × 2,5.
n, why := cacheRAMAuto(map[string]string{"CTX": "65536"}, nil, si)
if n != 13981 {
t.Fatalf("cacheRAMAuto = %d (%s), attendu 13981", n, why)
}
}
// Sans key_length, llama.cpp prend n_embd / n_head : nous aussi.
func TestGGUFStateBytesReplis(t *testing.T) {
g := GGUFInfo{BlockCount: 2, HeadCountKV: 2, EmbeddingLength: 1024, HeadCount: 8}
if p, _ := ggufStateBytes(g, "", ""); p != 2*2*(128+128)*2 {
t.Fatalf("repli n_embd/n_head : %v", p)
}
if p, _ := ggufStateBytes(GGUFInfo{BlockCount: 2, HeadCountKV: 2}, "", ""); p != 0 {
t.Fatalf("dimensions inconnues : %v, attendu 0", p)
}
}
func TestVRAMFromSMI(t *testing.T) {
const out = "0, 24576\n1, 16384\n"
cases := []struct {
cvd string
pci bool
want int64
}{
{"", false, 40960},
{"1", true, 16384},
{"0,1", true, 40960},
{"1", false, 16384}, // ordre inconnu : la plus petite carte
{"0", false, 16384},
{"GPU-1234", true, 0},
{"2", true, 0},
}
for _, c := range cases {
if got := vramFromSMI(out, c.cvd, c.pci); got != c.want {
t.Errorf("vramFromSMI(%q, %v) = %d, attendu %d", c.cvd, c.pci, got, c.want)
}
}
if got := vramFromSMI("[N/A]\n", "", false); got != 0 {
t.Errorf("sortie illisible : %d", got)
}
}
func TestCgroupMemMiB(t *testing.T) {
v2 := fstest.MapFS{
"sys/fs/cgroup/memory.max": {Data: []byte("17179869184\n")},
"sys/fs/cgroup/memory.current": {Data: []byte("4294967296\n")},
}
if l, c := cgroupMemMiB(v2); l != 16384 || c != 4096 {
t.Fatalf("v2 = %d/%d", l, c)
}
if l, _ := cgroupMemMiB(fstest.MapFS{"sys/fs/cgroup/memory.max": {Data: []byte("max\n")}}); l != 0 {
t.Fatalf("v2 sans limite = %d", l)
}
v1 := fstest.MapFS{"sys/fs/cgroup/memory/memory.limit_in_bytes": {Data: []byte("9223372036854771712\n")}}
if l, _ := cgroupMemMiB(v1); l != 0 {
t.Fatalf("v1 sans limite = %d", l)
}
}
func TestSlotSaveArgs(t *testing.T) {
want := []string{"--slot-save-path", "/data/slots"}
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
}{
{name: "boucle locale", cfg: map[string]string{"HOST": "127.0.0.1"}, want: want},
{name: "--host ::1 dans EXTRA_ARGS", extra: []string{"--host", "::1"}, want: want},
{name: "0.0.0.0 sans clé : ouvert à tous, pas de drapeau", cfg: map[string]string{}},
{name: "0.0.0.0 avec clé d'API", cfg: map[string]string{"HOST": "0.0.0.0"},
si: func(s *serveSysInfo) { s.APIKey = "k" }, want: want},
{name: "EXTRA_ARGS le pose déjà", cfg: map[string]string{"HOST": "127.0.0.1"},
extra: []string{"--slot-save-path", "/ailleurs"}},
{name: "CACHE_ISOLATE=off", cfg: map[string]string{"HOST": "127.0.0.1", "CACHE_ISOLATE": "off"}},
{name: "cache coupé : rien à isoler", cfg: map[string]string{"HOST": "127.0.0.1", "CACHE_RAM": "0"}},
{name: "cache coupé par EXTRA_ARGS", cfg: map[string]string{"HOST": "127.0.0.1"}, extra: []string{"-cram", "0"}},
{name: "moteur sans le drapeau", cfg: map[string]string{"HOST": "127.0.0.1"},
si: func(s *serveSysInfo) { s.Help = helpRecent }},
{name: "dossier indisponible", cfg: map[string]string{"HOST": "127.0.0.1"},
si: func(s *serveSysInfo) { s.SlotDir = "" }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := serveSysInfo{Help: helpCacheRAM, SlotDir: "/data/slots", ArgEnv: map[string]string{}}
if c.si != nil {
c.si(&si)
}
if got := slotSaveArgs(c.cfg, c.extra, si); !reflect.DeepEqual(got, c.want) {
t.Fatalf("slotSaveArgs = %v, attendu %v", got, c.want)
}
})
}
}
// Place dans la ligne complète : après --api-key, avant EXTRA_ARGS qui garde le
// dernier mot. Sans aide qui les connaisse, la ligne historique est intacte
// (TestBuildServeArgs).
func TestBuildServeArgsCacheRAM(t *testing.T) {
si := denseGPU()
si.APIKey, si.SlotDir = "k", "/data/slots"
args, _, _ := buildServeArgs(map[string]string{"CTX": "65536", "NGL": "999"}, []string{"--jinja"}, "/bin/llama-server", si)
got := strings.Join(args, " ")
if !strings.HasSuffix(got, "--api-key k --cache-ram 19660 --slot-save-path /data/slots --jinja") {
t.Fatalf("ligne = %s", got)
}
}
func TestPrepareSlotDir(t *testing.T) {
home := t.TempDir()
dir := prepareSlotDir(home, false)
if dir != filepath.Join(home, "slots") {
t.Fatalf("dossier = %q", dir)
}
stray := filepath.Join(dir, "slot0.bin")
if err := os.WriteFile(stray, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if prepareSlotDir(home, false) == "" {
t.Fatal("second appel")
}
if _, err := os.Stat(stray); !os.IsNotExist(err) {
t.Fatal("un fichier laissé dans slots/ doit être purgé au lancement")
}
// Un fichier à la place du dossier : pas de drapeau, plutôt qu'un moteur
// qui refuse de démarrer.
bad := t.TempDir()
if err := os.WriteFile(filepath.Join(bad, "slots"), nil, 0o600); err != nil {
t.Fatal(err)
}
if got := prepareSlotDir(bad, false); got != "" {
t.Fatalf("slots est un fichier : %q", got)
}
}
// L'éditeur demande en parallèle le cache de prompts, le second slot et les
// avis MoE : une seule lecture nvidia-smi pour tous, la sélection des cartes
// appliquée ensuite. Après le délai, une lecture neuve ; un échec n'est pas
// relancé dans la même salve.
func TestNvidiaVRAMShared(t *testing.T) {
var calls atomic.Int32
fail := false
prevQ := nvidiaTotalsQuery
nvidiaTotalsQuery = func(time.Duration) (string, error) {
calls.Add(1)
time.Sleep(20 * time.Millisecond) // les appels simultanés attendent la même lecture
if fail {
return "", errors.New("exit status 9")
}
return "0, 16311\n1, 12288\n", nil
}
t.Cleanup(func() {
nvidiaTotalsQuery = prevQ
smiTotals.mu.Lock()
smiTotals.exp = time.Time{} // les tests suivants relisent nvidia-smi
smiTotals.mu.Unlock()
})
expire := func() {
smiTotals.mu.Lock()
smiTotals.exp = time.Time{}
smiTotals.mu.Unlock()
}
expire()
var wg sync.WaitGroup
got := make([]int64, 3)
for i, cvd := range []string{"", "0", "1"} {
wg.Add(1)
go func() { defer wg.Done(); got[i] = nvidiaVRAMMiB(cvd, true, time.Second) }()
}
wg.Wait()
if n := calls.Load(); n != 1 {
t.Errorf("%d lectures nvidia-smi pour trois demandes simultanées", n)
}
if !reflect.DeepEqual(got, []int64{16311 + 12288, 16311, 12288}) {
t.Errorf("sélection des cartes : %v", got)
}
expire()
fail = true
if v := nvidiaVRAMMiB("", true, time.Second); v != 0 {
t.Errorf("échec : %d, attendu 0", v)
}
if v := nvidiaVRAMMiB("", true, time.Second); v != 0 || calls.Load() != 2 {
t.Errorf("échec relancé dans la même salve : %d lectures", calls.Load())
}
}
+332
View File
@@ -0,0 +1,332 @@
package loki
import (
"fmt"
"os"
"strconv"
"strings"
"sync"
)
// Points de reprise des modèles hybrides (--ctx-checkpoints,
// --checkpoint-min-step) et conservation du raisonnement (--reasoning-preserve).
//
// Un modèle hybride (couches récurrentes : Qwen3.5/3.6, Qwen3-Next…) ne sait pas
// rembobiner son état récurrent comme un cache KV : llama-server en garde des
// copies exactes, les points de reprise, et repart du plus proche quand la
// conversation diverge ou revient du cache de prompts. Avant b10864 (PR #28302),
// le moteur effaçait un point trop proche du précédent (moins de 8192 jetons)
// MÊME quand la liste avait de la place : la reprise retombait jusqu'à ~8 k
// jetons en arrière, recalculés à chaque tour d'outil. Rien de ce que voit le
// modèle ne change ici — un point de reprise est une copie octet pour octet de
// l'état — seul le temps de prefill, et la RAM hôte que ces copies occupent.
//
// Loki ne touche à rien sans savoir : le build n'est cru que pour un moteur
// officiel (voir engineBuildTrusted), le drapeau n'est posé que si l'aide le
// connaît, et l'utilisateur garde le dernier mot (clés du preset, EXTRA_ARGS,
// LLAMA_ARG_*). Tout ce qui décide est pur ; cmdServe fait les lectures.
const (
// engineMinRecommended : premier build où l'espacement minimal n'évince un
// point de reprise que lorsque la liste est pleine (PR #28302, 5d806aa).
engineMinRecommended = 10864
// engineBuildFloor : en dessous, le numéro ne prouve rien. Un llama.cpp
// compilé depuis un clone superficiel (--depth=1) annonce « build 1 », un
// fork numérote à sa façon : traiter ça comme un moteur ancien poserait un
// avis faux et un drapeau inutile sur un moteur récent.
engineBuildFloor = 5000
// engineCkptDefault : n_ctx_checkpoints par défaut de llama.cpp (common.h).
engineCkptDefault = 32
// ckptAutoMinStep : espacement posé d'office sur un moteur ancien. Plus serré
// que les 8192 du moteur, il garde des points qu'il aurait jetés ; jamais 0,
// qui laisserait l'éviction FIFO jeter le point du début de la conversation
// dans une longue boucle d'outils.
ckptAutoMinStep = 2048
// ckptFallbackMiB : poids d'un point de reprise quand le GGUF ne permet pas de
// le calculer (70 à 200 Mio mesurés sur les hybrides Qwen) — on prend le haut.
ckptFallbackMiB = 200
)
// ckptArgEnv : les variables qui règlent la même chose sans drapeau. llama.cpp
// les lit avant la ligne de commande : un drapeau de Loki les écraserait.
var ckptArgEnv = []string{"LLAMA_ARG_CTX_CHECKPOINTS", "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT",
"LLAMA_ARG_REASONING_PRESERVE", "LLAMA_ARG_CHAT_TEMPLATE_KWARGS"}
// probeCkptEnv complète ArgEnv avec ckptArgEnv.
func probeCkptEnv(si *serveSysInfo) {
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range ckptArgEnv {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
}
// hybridArchs : architectures hybrides connues, au cas où le GGUF ne porterait
// pas de clé ssm.* lisible.
var hybridArchs = map[string]bool{"qwen35": true, "qwen35moe": true, "qwen3next": true, "qwen4exp": true}
// ggufHybrid : le modèle a-t-il des couches récurrentes ? Jamais deviné d'après
// le nom du fichier : sans métadonnées, on répond non.
func ggufHybrid(g *GGUFInfo) bool {
return g != nil && (g.Hybrid || hybridArchs[strings.ToLower(g.Arch)])
}
// engineSource classe un moteur comme le panneau Moteur : celui de l'image,
// un moteur téléchargé par Loki depuis l'image officielle, le précompilé des
// releases officielles, ou tout le reste (« custom » : compilé, fork, chemin
// à la main).
func engineSource(bin string) string {
imageBin := providedEngineBin()
switch {
case imageBin != "" && samePath(bin, imageBin):
return "image"
case engineOwns(bin):
return "downloaded"
case prebuiltOwns(bin):
return "prebuilt"
}
return "custom"
}
// engineBuildTrust : le build n'est cru que pour un moteur publié par ggml-org,
// dont la CI numérote sur l'historique complet, et s'il est vraisemblable. 0 =
// inconnu : ni avis, ni drapeau automatique.
func engineBuildTrust(source string, build int) int {
if source == "custom" || build < engineBuildFloor {
return 0
}
return build
}
// engineBuildTrusted : build du moteur, ou 0 s'il n'est pas digne de confiance.
// Le « --version » est gardé par binaire, comme l'aide (binHelp), et la clé
// porte la taille et la date du fichier : un moteur recompilé au même chemin
// est relu.
func engineBuildTrusted(bin string) int {
src := engineSource(bin)
if src == "custom" {
return 0 // pas la peine de lancer le binaire
}
return engineBuildTrust(src, engineBuildCached(bin))
}
var (
buildMu sync.Mutex
buildCache = map[string]int{}
)
func engineBuildCached(bin string) int {
st, err := os.Stat(bin)
if err != nil {
return 0
}
key := fmt.Sprintf("%s|%d|%d", bin, st.Size(), st.ModTime().UnixNano())
buildMu.Lock()
defer buildMu.Unlock()
if b, ok := buildCache[key]; ok {
return b
}
b, _ := engineBuildOf(bin)
buildCache[key] = b
return b
}
// engineBuildNotice : l'avis du panneau Moteur et du journal de lancement pour
// un build trop ancien. Vide = rien à dire (à jour, ou build inconnu).
func engineBuildNotice(build int) string {
if build <= 0 || build >= engineMinRecommended {
return ""
}
return fmt.Sprintf("moteur b%d : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont "+
"évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b%d.",
build, engineMinRecommended)
}
// ckptCount : nombre de points de reprise par slot que le moteur gardera
// (CTX_CHECKPOINTS valide, sinon EXTRA_ARGS, sinon la variable, sinon le
// défaut). -1 = illisible ; un CTX_CHECKPOINTS illisible est ignoré (voir
// ckptArgs), le moteur garde alors son défaut.
func ckptCount(cfg map[string]string, extra []string, argEnv map[string]string) int {
v := flagValue(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints")
if v == "" {
v = argEnv["LLAMA_ARG_CTX_CHECKPOINTS"]
}
if v == "" {
if n, err := strconv.Atoi(strings.TrimSpace(cfg["CTX_CHECKPOINTS"])); err == nil && n >= 0 {
return n
}
return engineCkptDefault
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || n < 0 {
return -1
}
return n
}
// ckptSlots : nombre de slots que le moteur ouvrira, chacun avec sa propre liste
// de points de reprise. --parallel d'EXTRA_ARGS, sinon PARALLEL, sinon 1 (Loki
// pose toujours --parallel, voir buildServeArgs). Illisible ou « auto » (-1) :
// 4, ce que choisissent les moteurs récents — on compte large.
func ckptSlots(cfg map[string]string, extra []string) int {
v := flagValue(extra, "-np", "--parallel")
if v == "" {
v = strings.TrimSpace(cfg["PARALLEL"])
}
if v == "" {
return 1
}
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
return n
}
return 4
}
// ckptMiB : poids d'un point de reprise d'un hybride (état récurrent en f32).
func ckptMiB(g *GGUFInfo) int64 {
if g != nil {
if _, rec := ggufStateBytes(*g, "", ""); rec > 0 {
return (rec + (1 << 20) - 1) >> 20
}
}
return ckptFallbackMiB
}
// ramTight : le modèle occupe plus de 70 % de la RAM (mmap d'un gros MoE dont
// les experts vivent dans le cache de pages). Chaque Mio de points de reprise
// y est pris aux poids : plus de lectures disque, décodage plus lent.
func ramTight(si serveSysInfo) bool {
return si.RAMMiB > 0 && si.ModelBytes > 0 && float64(si.ModelBytes) > 0.7*float64(si.RAMMiB)*(1<<20)
}
// ckptArgs : --ctx-checkpoints et --checkpoint-min-step que Loki ajoute, et ce
// qu'il en dit.
//
// CTX_CHECKPOINTS=<n> → --ctx-checkpoints n (opt-in ; jamais de défaut relevé)
// CKPT_MIN_STEP=<n> → --checkpoint-min-step n (n > 0)
// clé absente → --checkpoint-min-step 2048 seulement sur un hybride,
// moteur officiel < b10864, RAM non serrée
//
// Chaque drapeau a sa propre garde : un --ctx-checkpoints écrit à la main ne
// coupe pas l'atténuation de l'espacement, qui est une autre question.
func ckptArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
hybrid := ggufHybrid(si.GGUF)
if v := strings.TrimSpace(cfg["CTX_CHECKPOINTS"]); v != "" {
n, err := strconv.Atoi(v)
switch {
case err != nil || n < 0:
notes = append(notes, "CTX_CHECKPOINTS="+v+" illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré")
case !strings.Contains(si.Help, "--ctx-checkpoints"):
notes = append(notes, "ce moteur ne connaît pas --ctx-checkpoints : CTX_CHECKPOINTS ignoré")
case hasAnyFlag(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints"):
case si.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] != "":
notes = append(notes, "LLAMA_ARG_CTX_CHECKPOINTS est posée : CTX_CHECKPOINTS ignoré")
default:
args = append(args, "--ctx-checkpoints", strconv.Itoa(n))
}
} else if hybrid && ramTight(si) && strings.Contains(si.Help, "--ctx-checkpoints") &&
!hasAnyFlag(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints") && si.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] == "" {
// Un conseil, pas un réglage : moins de points de reprise, c'est plus de
// prefill, et seul l'utilisateur peut mesurer lequel des deux coûte le plus.
notes = append(notes, fmt.Sprintf("modèle hybride de %d Gio pour %d Gio de RAM : jusqu'à %d points de reprise "+
"(~%d Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.",
si.ModelBytes>>30, si.RAMMiB>>10, engineCkptDefault, ckptMiB(si.GGUF)))
}
stepUser := hasAnyFlag(extra, "-cms", "--checkpoint-min-step") || si.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] != ""
if v := strings.TrimSpace(cfg["CKPT_MIN_STEP"]); v != "" {
n, err := strconv.Atoi(v)
switch {
case err != nil || n <= 0:
// 0 = « aucun minimum » pour le moteur : la liste se remplit de points
// serrés et l'éviction FIFO finit par jeter celui du début de la
// conversation. On ne le pose pas, sans se taire pour autant.
notes = append(notes, "CKPT_MIN_STEP="+v+" refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter "+
"le point de reprise du début de la conversation) : défaut du moteur")
case !strings.Contains(si.Help, "--checkpoint-min-step"):
notes = append(notes, "ce moteur ne connaît pas --checkpoint-min-step : CKPT_MIN_STEP ignoré")
case hasAnyFlag(extra, "-cms", "--checkpoint-min-step"):
case si.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] != "":
notes = append(notes, "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT est posée : CKPT_MIN_STEP ignoré")
default:
args = append(args, "--checkpoint-min-step", strconv.Itoa(n))
}
return args, notes
}
notice := engineBuildNotice(si.EngineBuild)
if !hybrid || notice == "" {
return args, notes
}
n := ckptCount(cfg, extra, si.ArgEnv)
switch {
case stepUser || !strings.Contains(si.Help, "--checkpoint-min-step"):
return args, append(notes, notice)
case n <= 0:
return args, append(notes, notice) // points de reprise coupés : l'espacement ne sert à rien
case si.RAMMiB <= 0 || si.ModelBytes <= 0 || ramTight(si):
return args, append(notes, notice+" Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle "+
"(CKPT_MIN_STEP=2048 pour l'imposer).")
}
// La liste est PAR SLOT : avec PARALLEL=4, quatre listes se remplissent.
total := n * ckptSlots(cfg, extra)
worst := int64(total) * ckptMiB(si.GGUF)
if si.RAMAvailMiB > 0 && worst > si.RAMAvailMiB/4 {
return args, append(notes, fmt.Sprintf("%s Pas d'espacement resserré d'office : %d points de reprise (~%d Mio) "+
"dépasseraient le quart de la RAM libre (CKPT_MIN_STEP=2048 pour l'imposer).", notice, total, worst))
}
args = append(args, "--checkpoint-min-step", strconv.Itoa(ckptAutoMinStep))
return args, append(notes, fmt.Sprintf("%s → --checkpoint-min-step %d : points de reprise exacts plus rapprochés, "+
"~%d Mio de RAM hôte au plus. CKPT_MIN_STEP=<jetons> pour fixer.", notice, ckptAutoMinStep, worst))
}
// reasoningPreserveArgs : REASONING_PRESERVE=on|off → --reasoning-preserve /
// --no-reasoning-preserve, seulement si la clé est posée.
//
// Le compromis, qui explique pourquoi Loki ne choisit pas : depuis b10763 le
// moteur active preserve_reasoning par défaut, et le gabarit garde alors la
// réflexion de TOUS les tours passés. Mais sans REASONING_ECHO, Loki ne renvoie
// pas le raisonnement des tours passés : un gabarit qui ne conserve rien de
// lui-même (Qwen3.6) rend alors des blocs de réflexion vides, là où « off » rend
// l'historique des anciens moteurs. À l'inverse, un gabarit qui conserve déjà de
// lui-même (Qwen3.8) changerait de rendu avec « off ». Aucun choix global n'est
// neutre pour tous les modèles : vide = défaut du moteur, inchangé.
//
// Avec REASONING_ECHO=on (llm_reasoning_echo.go), les deux clés se complètent
// sans se recouvrir : l'écho fournit le raisonnement, cette clé décide si le
// gabarit le garde au-delà du tour en cours. « on » donne alors un préfixe
// stable d'un message utilisateur à l'autre, au prix de plus de contexte ; le
// comptage de Loki suit le verdict de la sonde de gabarit (preservesHistory),
// pas cette clé, car un gabarit sans le réglage (Qwen3.5) l'ignore.
func reasoningPreserveArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
v := strings.ToLower(strings.TrimSpace(cfg["REASONING_PRESERVE"]))
if v == "" {
return nil, nil
}
flag := ""
switch v {
case "on":
flag = "--reasoning-preserve"
case "off":
flag = "--no-reasoning-preserve"
default:
return nil, []string{"REASONING_PRESERVE=" + cfg["REASONING_PRESERVE"] + " illisible (on ou off) : ignoré"}
}
switch {
case !strings.Contains(si.Help, "--no-reasoning-preserve"):
return nil, []string{"ce moteur ne connaît pas --reasoning-preserve : REASONING_PRESERVE ignoré"}
case hasAnyFlag(extra, "--reasoning-preserve", "--no-reasoning-preserve"):
return nil, nil
case strings.Contains(strings.Join(flagValues(extra, "--chat-template-kwargs"), " "), "preserve_reasoning"):
return nil, []string{"--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré"}
case si.ArgEnv["LLAMA_ARG_REASONING_PRESERVE"] != "":
return nil, []string{"LLAMA_ARG_REASONING_PRESERVE est posée : REASONING_PRESERVE ignoré"}
case strings.Contains(si.ArgEnv["LLAMA_ARG_CHAT_TEMPLATE_KWARGS"], "preserve_reasoning"):
return nil, []string{"LLAMA_ARG_CHAT_TEMPLATE_KWARGS fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré"}
}
return []string{flag}, nil
}
+247
View File
@@ -0,0 +1,247 @@
package loki
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
const helpCkpt = helpRecent + `-ctxcp, --ctx-checkpoints, --swa-checkpoints N
max number of context checkpoints to create per slot (default: 32)
-cms, --checkpoint-min-step N minimum spacing between context checkpoints in tokens (default: 8192, 0 = no minimum)
--reasoning-preserve, --no-reasoning-preserve
preserve reasoning trace in the full history, not just the last assistant message (default: enabled)
`
// hybridOld : un Qwen3.6 27B hybride de 17 Go sur un moteur officiel b10678,
// machine de 64 Go. État récurrent : 48 couches × (3 × 10240 + 128 × 6144) × 4
// octets ≈ 150 Mio par point de reprise.
func hybridOld() serveSysInfo {
return serveSysInfo{
Help: helpCkpt,
Model: "/models/Qwen3.6-27B.gguf",
GGUF: &GGUFInfo{Arch: "qwen35", Hybrid: true, BlockCount: 64, FullAttnInterval: 4, HeadCountKV: 4,
KeyLen: 256, ValLen: 256, SSMConv: 4, SSMInner: 6144, SSMState: 128, SSMGroups: 16},
ModelBytes: 17 << 30,
RAMMiB: 65536,
RAMAvailMiB: 50000,
ArgEnv: map[string]string{},
EngineBuild: 10678,
}
}
func TestCkptArgs(t *testing.T) {
cms := []string{"--checkpoint-min-step", "2048"}
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
notes int
}{
{name: "hybride, moteur b10678 : espacement resserré d'office", want: cms, notes: 1},
{name: "hybride, moteur b11351 : rien", si: func(s *serveSysInfo) { s.EngineBuild = 11351 }},
{name: "hybride, build pile au correctif : rien", si: func(s *serveSysInfo) { s.EngineBuild = engineMinRecommended }},
{name: "build inconnu (compilé, clone superficiel, fork) : ni avis ni drapeau",
si: func(s *serveSysInfo) { s.EngineBuild = 0 }},
{name: "modèle dense sur moteur ancien : rien",
si: func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "qwen3", BlockCount: 64} }},
{name: "métadonnées illisibles : jamais deviné au nom du fichier",
si: func(s *serveSysInfo) { s.GGUF = nil }},
{name: "arch hybride connue sans clé ssm.*", want: cms, notes: 1,
si: func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "qwen3next"} }},
{name: "CKPT_MIN_STEP=4096 passé tel quel, quel que soit le build",
cfg: map[string]string{"CKPT_MIN_STEP": "4096"}, want: []string{"--checkpoint-min-step", "4096"},
si: func(s *serveSysInfo) { s.EngineBuild = 0 }},
{name: "CKPT_MIN_STEP=0 refusé, défaut du moteur, et on le dit",
cfg: map[string]string{"CKPT_MIN_STEP": "0"}, notes: 1},
{name: "-cms d'EXTRA_ARGS : Loki se tait sur l'espacement",
extra: []string{"-cms", "1024"}, notes: 1},
{name: "--checkpoint-min-step=1024 d'EXTRA_ARGS", cfg: map[string]string{"CKPT_MIN_STEP": "4096"},
extra: []string{"--checkpoint-min-step=1024"}},
{name: "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT posée : pas d'espacement d'office", notes: 1,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] = "4096" }},
{name: "--ctx-checkpoints d'EXTRA_ARGS ne coupe pas l'espacement", extra: []string{"--ctx-checkpoints", "8"},
want: cms, notes: 1},
{name: "aide sans -cms : pas de drapeau, l'avis reste", notes: 1,
si: func(s *serveSysInfo) { s.Help = helpRecent }},
{name: "points de reprise coupés : l'espacement ne sert à rien", cfg: map[string]string{"CTX_CHECKPOINTS": "0"},
want: []string{"--ctx-checkpoints", "0"}, notes: 1},
{name: "MoE mmappé de 82 Go sur 64 Go : conseil, pas d'espacement d'office", notes: 2,
si: func(s *serveSysInfo) { s.ModelBytes = 82 << 30 }},
{name: "RAM inconnue : pas d'espacement d'office", notes: 1,
si: func(s *serveSysInfo) { s.RAMMiB = 0 }},
{name: "RAM libre trop juste pour 32 points", notes: 1,
si: func(s *serveSysInfo) { s.RAMAvailMiB = 8000 }},
// Une liste par slot : 2 × 32 × ~150 Mio tient dans le quart de 50 Go, 4 × non.
{name: "PARALLEL=2 : deux listes, encore dans le quart de la RAM libre", cfg: map[string]string{"PARALLEL": "2"},
want: cms, notes: 1},
{name: "PARALLEL=4 : quatre listes, trop pour la RAM libre", cfg: map[string]string{"PARALLEL": "4"}, notes: 1},
{name: "--parallel 4 d'EXTRA_ARGS l'emporte sur PARALLEL", cfg: map[string]string{"PARALLEL": "1"},
extra: []string{"--parallel", "4"}, notes: 1},
{name: "CTX_CHECKPOINTS=16 passé", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
want: []string{"--ctx-checkpoints", "16", "--checkpoint-min-step", "2048"}, notes: 1},
{name: "CTX_CHECKPOINTS illisible", cfg: map[string]string{"CTX_CHECKPOINTS": "beaucoup"}, want: cms, notes: 2},
{name: "CTX_CHECKPOINTS et LLAMA_ARG_CTX_CHECKPOINTS", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
want: cms, notes: 2,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] = "8" }},
{name: "CTX_CHECKPOINTS et -ctxcp d'EXTRA_ARGS", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
extra: []string{"-ctxcp", "8"}, want: cms, notes: 1},
{name: "moteur sans --ctx-checkpoints : CTX_CHECKPOINTS ignoré, dit", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
notes: 2, si: func(s *serveSysInfo) { s.Help = helpRecent }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := hybridOld()
if c.si != nil {
c.si(&si)
}
got, notes := ckptArgs(c.cfg, c.extra, si)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("ckptArgs = %v, attendu %v", got, c.want)
}
if len(notes) != c.notes {
t.Fatalf("notes = %q, attendu %d", notes, c.notes)
}
})
}
}
// Place dans la ligne complète : avant EXTRA_ARGS, qui garde le dernier mot.
func TestBuildServeArgsCkpt(t *testing.T) {
args, _, notes := buildServeArgs(map[string]string{"NGL": "999"}, []string{"--jinja"}, "/bin/llama-server", hybridOld())
if got := strings.Join(args, " "); !strings.HasSuffix(got, "--checkpoint-min-step 2048 --jinja") {
t.Fatalf("ligne = %s", got)
}
found := false
for _, n := range notes {
found = found || strings.Contains(n, "b10678")
}
if !found {
t.Fatalf("l'avis du moteur ancien manque : %q", notes)
}
}
func TestEngineBuildTrust(t *testing.T) {
cases := []struct {
src string
build int
want int
}{
{"downloaded", 10678, 10678},
{"image", 11351, 11351},
{"prebuilt", 10864, 10864},
{"custom", 10678, 0}, // compilé ou fork : numérotation inconnue
{"downloaded", 0, 0}, // --version illisible
{"image", 1, 0}, // clone superficiel
{"prebuilt", 4999, 0}, // sous le plancher
{"downloaded", 5000, 5000},
}
for _, c := range cases {
if got := engineBuildTrust(c.src, c.build); got != c.want {
t.Errorf("engineBuildTrust(%s, %d) = %d, attendu %d", c.src, c.build, got, c.want)
}
}
if engineBuildNotice(10678) == "" || engineBuildNotice(engineMinRecommended) != "" || engineBuildNotice(0) != "" {
t.Fatal("avis : seulement pour un build connu et antérieur au correctif")
}
}
func TestReasoningPreserveArgs(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
notes int
}{
{name: "clé absente : rien, même sur un moteur qui connaît le drapeau"},
{name: "on", cfg: map[string]string{"REASONING_PRESERVE": "on"}, want: []string{"--reasoning-preserve"}},
{name: "OFF", cfg: map[string]string{"REASONING_PRESERVE": "OFF"}, want: []string{"--no-reasoning-preserve"}},
{name: "illisible", cfg: map[string]string{"REASONING_PRESERVE": "peut-être"}, notes: 1},
{name: "moteur sans le drapeau", cfg: map[string]string{"REASONING_PRESERVE": "off"}, notes: 1,
si: func(s *serveSysInfo) { s.Help = helpRecent }},
{name: "EXTRA_ARGS tranche déjà", cfg: map[string]string{"REASONING_PRESERVE": "off"},
extra: []string{"--reasoning-preserve"}},
{name: "--chat-template-kwargs le fixe", cfg: map[string]string{"REASONING_PRESERVE": "off"},
extra: []string{"--chat-template-kwargs", `{"preserve_reasoning":true}`}, notes: 1},
{name: "LLAMA_ARG_REASONING_PRESERVE posée", cfg: map[string]string{"REASONING_PRESERVE": "off"}, notes: 1,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_REASONING_PRESERVE"] = "1" }},
{name: "LLAMA_ARG_CHAT_TEMPLATE_KWARGS le fixe", cfg: map[string]string{"REASONING_PRESERVE": "on"}, notes: 1,
si: func(s *serveSysInfo) {
s.ArgEnv["LLAMA_ARG_CHAT_TEMPLATE_KWARGS"] = `{"preserve_reasoning":false}`
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := serveSysInfo{Help: helpCkpt, ArgEnv: map[string]string{}}
if c.si != nil {
c.si(&si)
}
got, notes := reasoningPreserveArgs(c.cfg, c.extra, si)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("reasoningPreserveArgs = %v, attendu %v", got, c.want)
}
if len(notes) != c.notes {
t.Fatalf("notes = %q, attendu %d", notes, c.notes)
}
})
}
}
// Après une mise à jour, la version téléchargée qui tournait reste installée :
// c'est le retour arrière sans réseau. Le moteur de l'image, lui, n'a rien à
// garder sous engine/.
func TestEngineKeepAfterUpdate(t *testing.T) {
testHome(t)
for _, tag := range []string{"server-cuda-b10500", "server-cuda-b10678", "server-cuda-b10900"} {
d := filepath.Join(engineDir(), tag)
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "llama-server"), []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
}
nouveau := filepath.Join(engineDir(), "server-cuda-b10900", "llama-server")
ancien := filepath.Join(engineDir(), "server-cuda-b10678", "llama-server")
keep := engineKeepAfterUpdate(nouveau, ancien, nil)
if !reflect.DeepEqual(keep, map[string]bool{"server-cuda-b10900": true, "server-cuda-b10678": true}) {
t.Fatalf("gardés = %v", keep)
}
enginePrune(keep, nil)
if got := engineInstalled(); !reflect.DeepEqual(got, []string{"server-cuda-b10678", "server-cuda-b10900"}) {
t.Fatalf("après ménage : %v", got)
}
if keep := engineKeepAfterUpdate(nouveau, "/app/llama-server", nil); len(keep) != 1 {
t.Fatalf("moteur de l'image : %v", keep)
}
if keep := engineKeepAfterUpdate(nouveau, nouveau, nil); len(keep) != 1 {
t.Fatalf("même version : %v", keep)
}
}
// Nombre de slots : chacun a sa liste de points de reprise.
func TestCkptSlots(t *testing.T) {
cases := []struct {
cfg map[string]string
extra []string
want int
}{
{want: 1},
{cfg: map[string]string{"PARALLEL": "3"}, want: 3},
{cfg: map[string]string{"PARALLEL": "3"}, extra: []string{"-np", "2"}, want: 2},
{extra: []string{"--parallel=6"}, want: 6},
{extra: []string{"--parallel", "-1"}, want: 4},
{cfg: map[string]string{"PARALLEL": "auto"}, want: 4},
}
for _, c := range cases {
if got := ckptSlots(c.cfg, c.extra); got != c.want {
t.Errorf("ckptSlots(%v, %v) = %d, attendu %d", c.cfg, c.extra, got, c.want)
}
}
}
+260
View File
@@ -0,0 +1,260 @@
package loki
import (
"os"
"strconv"
"strings"
)
// Réglages d'expert du moteur local, tous éteints tant que le preset ne les
// demande pas. Aucun ne touche au modèle : ni poids, ni contexte, ni
// échantillonnage — seulement OÙ et COMMENT le même calcul s'exécute.
//
// FIT_TARGET=1024,3072 → --fit-target : marge VRAM laissée libre par carte
// OP_OFFLOAD_MIN_BATCH=N → GGML_OP_OFFLOAD_MIN_BATCH : seuil d'envoi au GPU
// des poids restés en RAM
// CUDA_GRAPH_OPT=on → GGML_CUDA_GRAPH_OPT=1 : branches Q/K/V en parallèle
//
// Une variable déjà présente dans l'environnement (gabarit Docker/Unraid) est
// un choix de l'utilisateur : jamais touchée. Les variables passent par
// l'environnement du seul « loki serve », qui devient llama-server : le reste
// du conteneur ne les voit pas.
// expertEnvKeys : les variables du moteur que ces réglages posent.
var expertEnvKeys = []string{"GGML_CUDA_GRAPH_OPT", "GGML_OP_OFFLOAD_MIN_BATCH"}
// fitArgEnv : les LLAMA_ARG_* qui, sans drapeau, décident de --fit et de sa
// marge. llama.cpp les applique quand la ligne de commande se tait.
var fitArgEnv = []string{"LLAMA_ARG_FIT", "LLAMA_ARG_FIT_TARGET", "LLAMA_ARG_TENSOR_SPLIT"}
// probeExpertEnv relève ce que l'environnement a déjà tranché. Appelée après
// probeServeGPUs, dont elle complète ArgEnv.
func probeExpertEnv(si *serveSysInfo) {
si.UserEnv = map[string]string{}
for _, k := range expertEnvKeys {
if v := os.Getenv(k); v != "" {
si.UserEnv[k] = v
}
}
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range fitArgEnv {
if v := os.Getenv(k); v != "" {
si.ArgEnv[k] = v
}
}
}
// fitTargetMin : la marge par défaut de llama.cpp (common.h), en Mio. En
// dessous, fit range plus serré que ce qu'il sait estimer — le tampon du modèle
// MTP, qu'il ne mesure pas toujours, suffit alors à ramener le « cudaMalloc
// failed: out of memory » au premier long prompt. On remonte, en le disant.
const fitTargetMin = 1024
// fitTargetMaxValues : llama.cpp refuse (et le serveur meurt en boucle) une
// liste aussi longue que son nombre maximal de cartes ; 8 reste loin du bord.
const fitTargetMaxValues = 8
// parseFitTarget valide FIT_TARGET : des entiers en Mio séparés par « , » ou
// « / », une valeur par carte dans l'ordre que voit le moteur (une seule vaut
// pour toutes). Jamais transmis brut : llama.cpp lit chaque valeur avec
// std::stoull, qui lève sur le moindre caractère parasite — moteur mort au
// démarrage, relancé en boucle par systemd ou Docker.
func parseFitTarget(v string) (vals []int, raised bool, ok bool) {
parts := strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == '/' })
if len(parts) == 0 || len(parts) > fitTargetMaxValues ||
strings.Count(v, ",")+strings.Count(v, "/") != len(parts)-1 {
return nil, false, false
}
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" || strings.Trim(p, "0123456789") != "" || len(p) > 7 {
return nil, false, false
}
n, _ := strconv.Atoi(p)
if n < fitTargetMin {
n, raised = fitTargetMin, true
}
vals = append(vals, n)
}
return vals, raised, true
}
// fitTargetArgs traduit FIT_TARGET en --fit-target. Fonction pure.
//
// La marge règle combien de VRAM --fit laisse libre sur chaque carte ; sur deux
// cartes inégales (5060 Ti + 3060), une marge plus large sur la lente pousse
// plus de couches vers la rapide. Placement seulement : mêmes poids, mêmes
// types de cache, même contexte. Rien n'est posé quand :
//
// - le moteur ne connaît pas --fit-target (un drapeau inconnu le tue) ;
// - EXTRA_ARGS ou LLAMA_ARG_FIT_TARGET l'ont déjà fixée ;
// - le contexte n'est pas un nombre positif : avec -c 0, fit a le droit de
// RÉDUIRE le contexte pour tenir, et une marge plus large l'y pousserait
// sans un mot — de l'information perdue pour le modèle ;
// - --fit ne tournera pas (voir fitBlocker) : la clé ne ferait rien, et une
// comparaison A/B mesurerait deux fois le même placement.
func fitTargetArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
raw := strings.TrimSpace(cfg["FIT_TARGET"])
if raw == "" {
return nil, nil
}
ignore := func(why string) (a, n []string) {
return nil, []string{"FIT_TARGET=" + raw + " ignoré : " + why}
}
vals, raised, ok := parseFitTarget(raw)
switch {
case !ok:
return ignore("des Mio par carte, ex. 1024,3072 (8 valeurs au plus)")
case !strings.Contains(si.Help, "--fit-target"):
return ignore("ce moteur ne connaît pas --fit-target")
case hasAnyFlag(extra, "-fitt", "--fit-target"):
return ignore("--fit-target déjà dans EXTRA_ARGS")
case si.ArgEnv["LLAMA_ARG_FIT_TARGET"] != "":
return ignore("LLAMA_ARG_FIT_TARGET déjà posé dans l'environnement")
}
if ctx, ok := ctxFixed(cfg, extra); !ok {
return ignore("contexte « " + ctx + " » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré")
}
if why := fitBlocker(cfg, extra, si); why != "" {
return ignore("--fit inactif (" + why + "), la marge ne servirait à rien")
}
s := make([]string, len(vals))
for i, n := range vals {
s[i] = strconv.Itoa(n)
}
v := strings.Join(s, ",")
note := "--fit-target " + v + " (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — " +
"vérifie « offloaded N/N layers » dans le journal"
if raised {
note += " ; marges sous 1024 Mio remontées à 1024"
}
return []string{"--fit-target", v}, []string{note}
}
// fitBlocker dit pourquoi llama.cpp ne lancera PAS --fit (common/fit.cpp :
// « already set by user, abort »), ou vide s'il le lancera. Les couches GPU
// sont lues comme buildServeArgs les écrira : EXTRA_ARGS d'abord, sinon la
// traduction de NGL par nglArgs (999 y devient auto), sinon — NGL=auto, aucun
// drapeau — la variable du moteur.
func fitBlocker(cfg map[string]string, extra []string, si serveSysInfo) string {
fit := flagValue(extra, "-fit", "--fit")
if fit == "" {
fit = si.ArgEnv["LLAMA_ARG_FIT"]
}
switch strings.ToLower(strings.TrimSpace(fit)) {
case "off", "disabled", "false", "0":
return "--fit off"
}
ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
if ngl == "" {
if a, _ := nglArgs(cfg["NGL"], helpFitsLayersItself(si.Help)); len(a) == 2 {
ngl = a[1]
} else {
ngl = si.ArgEnv["LLAMA_ARG_N_GPU_LAYERS"]
}
}
// -1 est la valeur par défaut du moteur, celle qu'« auto » écrit : fit ne
// la prend pas pour un choix de l'utilisateur.
if ngl = strings.TrimSpace(ngl); ngl != "" && ngl != "-1" && !strings.EqualFold(ngl, "auto") {
return "couches GPU fixées à " + ngl
}
if hasAnyFlag(extra, "-ts", "--tensor-split") || si.ArgEnv["LLAMA_ARG_TENSOR_SPLIT"] != "" {
return "--tensor-split"
}
if r := tensorOverride(extra, si.ArgEnv); r != "" {
return r
}
sm := flagValue(extra, "-sm", "--split-mode")
if sm == "" {
sm = si.ArgEnv["LLAMA_ARG_SPLIT_MODE"]
}
// row ET tensor : fit abandonne sur l'une comme sur l'autre (« not
// implemented », common/fit.cpp).
if sm = strings.ToLower(strings.TrimSpace(sm)); sm == "row" || sm == "tensor" {
return "-sm " + sm
}
return ""
}
// opOffloadMinBatchEnv traduit OP_OFFLOAD_MIN_BATCH en GGML_OP_OFFLOAD_MIN_BATCH.
// Fonction pure.
//
// Quand des poids restent en RAM (experts MoE sur CPU), llama.cpp recopie vers
// le GPU ceux dont un lot a besoin dès qu'il compte au moins 32 jetons ; en
// dessous, il calcule sur CPU. Monter le seuil garde sur CPU les lots plus
// courts : utile seulement si une spéculation ngram propose des brouillons de
// 32 jetons ou plus (les lots de vérification MTP, quelques jetons, restent
// déjà sous 32). Le prix : un tour d'outil de 32 à N-1 jetons se calcule lui
// aussi sur CPU. N se trouve à la mesure, pas d'office. Même distribution ;
// les logits ne sont pas identiques au bit près, comme déjà entre décodage CPU
// et prefill GPU.
func opOffloadMinBatchEnv(cfg map[string]string, si serveSysInfo) (val string, notes []string) {
raw := strings.TrimSpace(cfg["OP_OFFLOAD_MIN_BATCH"])
if raw == "" || si.UserEnv["GGML_OP_OFFLOAD_MIN_BATCH"] != "" {
return "", nil
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > 1<<20 {
return "", []string{"OP_OFFLOAD_MIN_BATCH=" + raw + " ignoré : un nombre de jetons, ex. 128"}
}
v := strconv.Itoa(n)
return v, []string{"GGML_OP_OFFLOAD_MIN_BATCH=" + v + " (OP_OFFLOAD_MIN_BATCH) : lots de moins de " + v +
" jetons calculés sur CPU pour les poids restés en RAM"}
}
// graphOptEnv traduit CUDA_GRAPH_OPT=on en GGML_CUDA_GRAPH_OPT=1. Fonction pure.
//
// Au décodage, llama.cpp lance alors les trois branches Q, K et V de chaque
// couche sur des flux CUDA concurrents. Chaque opération garde son noyau et ses
// entrées : sortie identique au jeton près en amont (PR #28198, perplexité
// inchangée). Gain modeste — quelques % sur un MoE tout GPU, rien sur un dense,
// dilué quand les experts tournent sur CPU ou que MTP vérifie par lots. Sans
// effet avant #28198 en multi-GPU ou si les graphes CUDA sont coupés.
//
// Expérimental : sur une architecture qu'il n'a pas vue, le repérage des
// branches peut s'arrêter sur un GGML_ASSERT, et le moteur redémarre en boucle.
// Une variable ne se détecte pas dans l'aide du moteur : on le dit au
// lancement, pour que « CUDA_GRAPH_OPT=off puis redémarrer » vienne à l'esprit.
//
// Refusé quand Q, K et V d'une même couche pourraient finir sur deux cartes
// (-sm row/tensor, -ot visant l'attention) : #28198 en fait une condition
// d'exactitude.
func graphOptEnv(cfg map[string]string, extra []string, si serveSysInfo) (val string, notes []string) {
raw := strings.TrimSpace(cfg["CUDA_GRAPH_OPT"])
switch strings.ToLower(raw) {
case "", "off":
return "", nil
case "on":
default:
return "", []string{"CUDA_GRAPH_OPT=" + raw + " ignoré (on ou off)"}
}
if si.UserEnv["GGML_CUDA_GRAPH_OPT"] != "" {
return "", nil
}
if why := qkvSplitRisk(extra, si.ArgEnv); why != "" {
return "", []string{"CUDA_GRAPH_OPT ignoré : " + why + " peut séparer Q, K et V d'une couche entre cartes"}
}
return "1", []string{"GGML_CUDA_GRAPH_OPT=1 (CUDA_GRAPH_OPT, expérimental) — si le moteur s'arrête sur un " +
"GGML_ASSERT, CUDA_GRAPH_OPT=off puis redémarre"}
}
// qkvSplitRisk : un placement qui pourrait répartir les tenseurs d'attention
// d'une même couche sur plusieurs appareils. La découpe par couches (défaut,
// --tensor-split compris) garde chaque couche entière : rien à craindre.
func qkvSplitRisk(extra []string, argEnv map[string]string) string {
sm := flagValue(extra, "-sm", "--split-mode")
if sm == "" {
sm = argEnv["LLAMA_ARG_SPLIT_MODE"]
}
if sm = strings.ToLower(strings.TrimSpace(sm)); sm == "row" || sm == "tensor" {
return "-sm " + sm
}
for _, v := range append(flagValues(extra, "-ot", "--override-tensor"), argEnv["LLAMA_ARG_OVERRIDE_TENSOR"]) {
if strings.Contains(strings.ToLower(v), "attn") {
return "-ot sur l'attention"
}
}
return ""
}
+218
View File
@@ -0,0 +1,218 @@
package loki
import (
"reflect"
"testing"
)
// helpFit : un moteur récent qui connaît --fit et sa marge.
const helpFit = helpRecent + `-fit, --fit [on|off] whether to adjust unset arguments to fit in device memory
-fitt, --fit-target MiB0,MiB1,... target margin per device for --fit (default: 1024)
`
// TestFitTargetArgs : --fit-target n'est passé que validé, sur un moteur qui le
// connaît, avec un contexte fixé, et quand --fit tournera vraiment.
func TestFitTargetArgs(t *testing.T) {
fit := serveSysInfo{Help: helpFit}
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want []string
wantNotes int
}{
{name: "clé absente : rien, sans un mot",
cfg: map[string]string{}, si: fit},
{name: "deux marges : passées, et on le dit",
cfg: map[string]string{"FIT_TARGET": "1024,3072"}, si: fit,
want: []string{"--fit-target", "1024,3072"}, wantNotes: 1},
{name: "séparateur / : normalisé en virgules",
cfg: map[string]string{"FIT_TARGET": "2048/1536"}, si: fit,
want: []string{"--fit-target", "2048,1536"}, wantNotes: 1},
{name: "une seule valeur : vaut pour toutes les cartes",
cfg: map[string]string{"FIT_TARGET": " 2048 "}, si: fit,
want: []string{"--fit-target", "2048"}, wantNotes: 1},
{name: "marge sous le défaut : remontée à 1024",
cfg: map[string]string{"FIT_TARGET": "256,3072"}, si: fit,
want: []string{"--fit-target", "1024,3072"}, wantNotes: 1},
{name: "valeur non numérique : jamais transmise (stoull ferait mourir le moteur)",
cfg: map[string]string{"FIT_TARGET": "1G,2G"}, si: fit, wantNotes: 1},
{name: "valeur négative : refusée",
cfg: map[string]string{"FIT_TARGET": "-1024"}, si: fit, wantNotes: 1},
{name: "séparateur doublé : refusé",
cfg: map[string]string{"FIT_TARGET": "1024,,2048"}, si: fit, wantNotes: 1},
{name: "trop de valeurs : refusé",
cfg: map[string]string{"FIT_TARGET": "1,2,3,4,5,6,7,8,9"}, si: fit, wantNotes: 1},
{name: "moteur sans --fit-target : rien, le drapeau le tuerait",
cfg: map[string]string{"FIT_TARGET": "1024,3072"}, si: serveSysInfo{Help: helpRecent}, wantNotes: 1},
{name: "-fitt déjà dans EXTRA_ARGS : l'utilisateur a tranché",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-fitt 2048"}, si: fit, wantNotes: 1},
{name: "LLAMA_ARG_FIT_TARGET dans l'environnement : intouché",
cfg: map[string]string{"FIT_TARGET": "1024"},
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_FIT_TARGET": "4096"}}, wantNotes: 1},
{name: "CTX=0 : fit pourrait réduire le contexte, refusé",
cfg: map[string]string{"FIT_TARGET": "1024,3072", "CTX": "0"}, si: fit, wantNotes: 1},
{name: "-c 0 dans EXTRA_ARGS l'emporte sur CTX : refusé",
cfg: map[string]string{"FIT_TARGET": "1024", "CTX": "65536", "EXTRA_ARGS": "-c 0"}, si: fit, wantNotes: 1},
{name: "CTX absent : Loki passe -c 32768, accepté",
cfg: map[string]string{"FIT_TARGET": "1024"}, si: fit,
want: []string{"--fit-target", "1024"}, wantNotes: 1},
{name: "NGL=999 : devient -ngl auto, fit tourne",
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "999"}, si: fit,
want: []string{"--fit-target", "1024"}, wantNotes: 1},
{name: "NGL chiffré : fit abandonne, rien",
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "40"}, si: fit, wantNotes: 1},
{name: "NGL=all : fit abandonne, rien",
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "all"}, si: fit, wantNotes: 1},
{name: "-ngl auto dans EXTRA_ARGS : fit tourne",
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "40", "EXTRA_ARGS": "-ngl auto"}, si: fit,
want: []string{"--fit-target", "1024"}, wantNotes: 1},
{name: "NGL=auto et LLAMA_ARG_N_GPU_LAYERS=30 : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "auto"},
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_N_GPU_LAYERS": "30"}}, wantNotes: 1},
{name: "--tensor-split : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "--tensor-split 0.965,0.035"}, si: fit, wantNotes: 1},
{name: "preset MoE (-ot, --n-cpu-moe) : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": `-ot "per_layer_token_embd=CPU" --n-cpu-moe 40`},
si: fit, wantNotes: 1},
{name: "--cpu-moe : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-cmoe"}, si: fit, wantNotes: 1},
{name: "--fit off : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "--fit off"}, si: fit, wantNotes: 1},
{name: "LLAMA_ARG_FIT=off : rien",
cfg: map[string]string{"FIT_TARGET": "1024"},
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_FIT": "off"}}, wantNotes: 1},
{name: "-sm row : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-sm row"}, si: fit, wantNotes: 1},
{name: "-sm tensor : fit abandonne aussi, rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "--split-mode tensor"}, si: fit, wantNotes: 1},
{name: "LLAMA_ARG_SPLIT_MODE=tensor : rien",
cfg: map[string]string{"FIT_TARGET": "1024"},
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_SPLIT_MODE": "tensor"}}, wantNotes: 1},
{name: "-sm layer : fit tourne",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-sm layer"}, si: fit,
want: []string{"--fit-target", "1024"}, wantNotes: 1},
{name: "-ngl -1 (le défaut du moteur) : fit tourne",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-ngl -1"}, si: fit,
want: []string{"--fit-target", "1024"}, wantNotes: 1},
{name: "--device choisi : le placement reste à fit, accepté",
cfg: map[string]string{"FIT_TARGET": "1024,3072", "EXTRA_ARGS": "--device CUDA1,CUDA0"}, si: fit,
want: []string{"--fit-target", "1024,3072"}, wantNotes: 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, notes := fitTargetArgs(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("args = %q, attendu %q", got, c.want)
}
if len(notes) != c.wantNotes {
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
}
})
}
}
// TestGraphOptEnv : GGML_CUDA_GRAPH_OPT=1 seulement sur demande, jamais
// par-dessus l'environnement, jamais quand Q/K/V pourraient changer de carte.
func TestGraphOptEnv(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want string
wantNotes int
}{
{name: "clé absente : rien", cfg: map[string]string{}},
{name: "off : rien", cfg: map[string]string{"CUDA_GRAPH_OPT": "off"}},
{name: "on : posé, et on le dit", cfg: map[string]string{"CUDA_GRAPH_OPT": "on"}, want: "1", wantNotes: 1},
{name: "ON (casse libre) : posé", cfg: map[string]string{"CUDA_GRAPH_OPT": "ON"}, want: "1", wantNotes: 1},
{name: "valeur inconnue : ignorée, dite", cfg: map[string]string{"CUDA_GRAPH_OPT": "2"}, wantNotes: 1},
{name: "variable déjà posée (même à 0) : intouchée",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on"},
si: serveSysInfo{UserEnv: map[string]string{"GGML_CUDA_GRAPH_OPT": "0"}}},
{name: "découpe par couches + --tensor-split : couches entières, posé",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": "--device CUDA1,CUDA0 -ts 0.6,0.4"},
want: "1", wantNotes: 1},
{name: "-sm row : refusé",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": "-sm row"}, wantNotes: 1},
{name: "LLAMA_ARG_SPLIT_MODE=tensor : refusé",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on"},
si: serveSysInfo{ArgEnv: map[string]string{"LLAMA_ARG_SPLIT_MODE": "tensor"}}, wantNotes: 1},
{name: "-ot sur l'attention : refusé",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": `-ot "blk\.1\.attn_k.*=CUDA1"`}, wantNotes: 1},
{name: "-ot des experts seulement : posé",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": `-ot "ffn_.*_exps=CPU" --n-cpu-moe 40`},
want: "1", wantNotes: 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, notes := graphOptEnv(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
if got != c.want {
t.Fatalf("valeur = %q, attendu %q", got, c.want)
}
if len(notes) != c.wantNotes {
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
}
})
}
}
// TestOpOffloadMinBatchEnv : la variable n'existe que si la clé est posée et
// valide, et ne remplace jamais celle de l'environnement.
func TestOpOffloadMinBatchEnv(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want string
wantNotes int
}{
{name: "clé absente : rien", cfg: map[string]string{}},
{name: "128 : posé", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "128"}, want: "128", wantNotes: 1},
{name: "espaces autour : posé", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": " 64 "}, want: "64", wantNotes: 1},
{name: "0 : ignoré, dit", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "0"}, wantNotes: 1},
{name: "texte : ignoré, dit", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "beaucoup"}, wantNotes: 1},
{name: "variable déjà posée : intouchée",
cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "128"},
si: serveSysInfo{UserEnv: map[string]string{"GGML_OP_OFFLOAD_MIN_BATCH": "32"}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, notes := opOffloadMinBatchEnv(c.cfg, c.si)
if got != c.want {
t.Fatalf("valeur = %q, attendu %q", got, c.want)
}
if len(notes) != c.wantNotes {
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
}
})
}
}
// Les trois clés absentes, la ligne de commande et l'environnement ne bougent
// pas ; posées, --fit-target arrive avant EXTRA_ARGS et les variables vont
// dans env, nulle part ailleurs.
func TestBuildServeArgsExpertToggles(t *testing.T) {
const bin, model = "/opt/llama/llama-server", "/models/Qwen3.6-27B-Q4_K_M.gguf"
si := serveSysInfo{Help: helpFit, Model: model}
cfg := map[string]string{"CTX": "65536", "EXTRA_ARGS": "--device CUDA0"}
base, baseEnv, baseNotes := buildServeArgs(cfg, splitArgs(cfg["EXTRA_ARGS"]), bin, si)
if len(baseEnv) != 0 || len(baseNotes) != 0 {
t.Fatalf("sans les clés : env = %v, notes = %q", baseEnv, baseNotes)
}
on := map[string]string{"CTX": "65536", "EXTRA_ARGS": "--device CUDA0",
"FIT_TARGET": "1024,3072", "OP_OFFLOAD_MIN_BATCH": "128", "CUDA_GRAPH_OPT": "on"}
args, env, notes := buildServeArgs(on, splitArgs(on["EXTRA_ARGS"]), bin, si)
n := len(base)
want := append(append(append([]string{}, base[:n-2]...), "--fit-target", "1024,3072"), base[n-2:]...)
if !reflect.DeepEqual(args, want) {
t.Fatalf("args\n got %q\nwant %q", args, want)
}
wantEnv := map[string]string{"GGML_CUDA_GRAPH_OPT": "1", "GGML_OP_OFFLOAD_MIN_BATCH": "128"}
if !reflect.DeepEqual(env, wantEnv) {
t.Fatalf("env = %v, want %v", env, wantEnv)
}
if len(notes) != 3 {
t.Fatalf("notes = %q", notes)
}
}
+217
View File
@@ -0,0 +1,217 @@
package loki
import (
"fmt"
"os"
"strconv"
"strings"
)
// Garde-fous de fidélité. Loki ne choisit JAMAIS à la place de l'utilisateur un
// réglage qui change ce que le modèle calcule : pas de cache KV quantifié posé
// d'office, pas de réutilisation approximative du cache, pas de contexte qui
// glisse en jetant des jetons. Ces réglages restent possibles — ce sont des
// compromis légitimes quand la VRAM manque — mais ils doivent se VOIR : un
// preset qui tourne en q8_0 depuis EXTRA_ARGS pendant que l'interface affiche
// « f16 » est exactement l'altération silencieuse qu'on refuse.
//
// Tout ici est pur : buildServeArgs en tire des notes, cmdServe les écrit.
// fidelityArgEnv : les LLAMA_ARG_* qui, sans drapeau, posent un cache
// quantifié, un cache approché ou la vision. llama.cpp les applique AVANT la
// ligne de commande : un -ctk de KV_TYPE ou d'EXTRA_ARGS les écrase, mais seuls
// ils décident. LLAMA_ARG_NO_CONTEXT_SHIFT est celle des moteurs anciens.
var fidelityArgEnv = []string{"LLAMA_ARG_CACHE_TYPE_K", "LLAMA_ARG_CACHE_TYPE_V",
"LLAMA_ARG_CONTEXT_SHIFT", "LLAMA_ARG_NO_CONTEXT_SHIFT", "LLAMA_ARG_CACHE_REUSE",
"LLAMA_ARG_MMPROJ", "LLAMA_ARG_MMPROJ_URL", "LLAMA_ARG_SPEC_SYNTH_LEN"}
// probeFidelityEnv complète ArgEnv avec fidelityArgEnv. Appelée après
// probeServeGPUs, qui crée ArgEnv.
func probeFidelityEnv(si *serveSysInfo) {
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range fidelityArgEnv {
if v, ok := os.LookupEnv(k); ok && v != "" {
si.ArgEnv[k] = v
}
}
}
// effectiveKVTypes donne les types de cache K et V que le moteur utilisera
// VRAIMENT. Trois sources, de la plus faible à la plus forte : les variables
// LLAMA_ARG_CACHE_TYPE_K/V (lues avant la ligne de commande), le preset
// (serveKVTypes, traduit en -ctk/-ctv), puis EXTRA_ARGS, qui ferme la ligne :
// llama-server retient la dernière occurrence d'un drapeau, un -ctk q8_0 écrit
// à la main l'emporte donc sur KV_TYPE. src nomme la source la plus forte qui a
// tranché l'un des deux (vide : aucune). Vide = défaut du moteur (f16).
func effectiveKVTypes(cfg map[string]string, extra []string, argEnv map[string]string) (k, v, src string) {
k, v = argEnv["LLAMA_ARG_CACHE_TYPE_K"], argEnv["LLAMA_ARG_CACHE_TYPE_V"]
if k != "" || v != "" {
src = "défini par LLAMA_ARG_CACHE_TYPE_K/V"
}
if ck, cv := serveKVTypes(cfg); ck != "" || cv != "" {
src = "KV_TYPE"
if ck != "" {
k = ck
}
if cv != "" {
v = cv
}
}
if s := flagValue(extra, "-ctk", "--cache-type-k"); s != "" {
k, src = s, "défini par EXTRA_ARGS"
}
if s := flagValue(extra, "-ctv", "--cache-type-v"); s != "" {
v, src = s, "défini par EXTRA_ARGS"
}
return k, v, src
}
// kvFidelity classe un type de cache par rapport au f16 de référence. Rang 0 =
// mêmes calculs (f16, ou f32 qui ne perd rien) ; plus le rang monte, plus les
// sorties s'en écartent. Un type inconnu (sorti après cette version) est
// supposé altérer : on préfère un avertissement de trop à un silence.
func kvFidelity(t string) (rank int, label string) {
switch strings.ToLower(strings.TrimSpace(t)) {
case "", "f16", "f32":
return 0, ""
case "bf16":
return 1, "numérique différente de f16 (mantisse plus courte)"
case "q8_0", "q5_0", "q5_1":
return 2, "modifie légèrement les sorties par rapport à f16"
case "q4_0", "q4_1", "iq4_nl":
return 3, "perte mesurable par rapport à f16"
default:
return 2, "modifie les sorties par rapport à f16"
}
}
// kvFidelityNote dit, quand le cache n'est pas en f16, ce que ce choix coûte en
// fidélité et d'où il vient. Le choix reste celui du preset : Loki ne le change
// jamais, ni dans un sens ni dans l'autre. Pas d'estimation de la VRAM qu'un
// retour en f16 demanderait : sans les métadonnées du GGUF, ce serait un chiffre
// inventé. En revanche, si le placement est figé à la main (-ot, --n-cpu-moe N>0,
// en drapeau ou en variable : tensorOverride), --fit ne tourne pas et ne
// rattrapera pas ce surplus — mieux vaut le savoir avant de basculer et de
// finir en « out of memory ».
func kvFidelityNote(k, v, src string, extra []string, argEnv map[string]string) string {
rk, lk := kvFidelity(k)
rv, lv := kvFidelity(v)
if rk == 0 && rv == 0 {
return ""
}
label := lk
if rv > rk {
label = lv
}
note := fmt.Sprintf("cache KV %s/%s (%s) : %s — choix de l'utilisateur, laissé tel quel.",
orF16(k), orF16(v), src, label)
if tensorOverride(extra, argEnv) != "" {
note += " Repasser en f16 demande plus de VRAM, et le placement fixé par -ot/--n-cpu-moe " +
"empêche --fit de compenser : il faudrait sans doute relever --n-cpu-moe."
}
return note
}
func orF16(t string) string {
if t == "" {
return "f16"
}
return t
}
// lossyCacheNotes avertit des réglages de cache qui changent ce que voit le
// modèle. Seuls deux le font :
//
// - le glissement de contexte : contexte plein → le moteur JETTE des jetons
// anciens et continue, le modèle perd une partie de la conversation sans le
// savoir ;
// - --cache-reuse N (N > 0) : recolle des morceaux de cache calculés sous un
// AUTRE préfixe, simplement décalés — le résultat n'est plus celui d'un
// calcul complet. --cache-reuse 0 le désactive : rien à dire.
//
// --swa-full n'en fait PAS partie : il garde le cache complet des couches à
// fenêtre glissante, ce qui rend la réutilisation du préfixe exacte (au prix de
// VRAM), et ne fait rien sur un modèle sans SWA. Correspondance exacte des noms
// (flagValue) : --no-context-shift ne déclenche rien, et la dernière occurrence
// gagne comme dans llama-server. Les variables LLAMA_ARG_* comptent quand la
// ligne de commande se tait.
//
// Avec la vision chargée (MMPROJ, --mmproj d'EXTRA_ARGS ou LLAMA_ARG_MMPROJ),
// llama.cpp désactive lui-même les deux (non pris en charge en multimodal) : on
// le dit plutôt que de crier au loup. Les modèles hybrides ou récurrents les
// ignorent aussi, mais sans métadonnées du GGUF on ne sait pas les reconnaître
// ici : on se tait sur ce point plutôt que deviner.
func lossyCacheNotes(extra []string, si serveSysInfo) []string {
var notes []string
ignored := ""
if si.MMProj != "" || hasAnyFlag(extra, "-mm", "--mmproj", "-mmu", "--mmproj-url") ||
si.ArgEnv["LLAMA_ARG_MMPROJ"] != "" || si.ArgEnv["LLAMA_ARG_MMPROJ_URL"] != "" {
ignored = " (vision chargée : llama.cpp l'ignore de toute façon)"
}
switch on, src := contextShift(extra, si.ArgEnv, si.Help); {
case on && src == "":
notes = append(notes, "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte "+
"plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir"+
ignored+". --no-context-shift dans EXTRA_ARGS l'en empêche.")
case on:
notes = append(notes, "avertissement : --context-shift ("+src+") — contexte plein, le moteur jette "+
"des jetons anciens et le modèle perd une partie de la conversation sans le savoir"+ignored+".")
}
s, src := flagValue(extra, "--cache-reuse"), "EXTRA_ARGS"
if s == "" {
s, src = si.ArgEnv["LLAMA_ARG_CACHE_REUSE"], "LLAMA_ARG_CACHE_REUSE"
}
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && n > 0 {
notes = append(notes, fmt.Sprintf("avertissement : --cache-reuse %d (%s) — réutilise des morceaux "+
"de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul "+
"complet%s.", n, src, ignored))
}
// Acceptation SYNTHÉTIQUE (« benchmarking only ») : le moteur garde des
// jetons du brouillon au hasard, sans les comparer à ce qu'aurait tiré le
// modèle. Loki ne les pose jamais ; écrits à la main, on le crie.
if f := hasFlagPrefix(extra, "--spec-synth-"); f != "" || si.ArgEnv["LLAMA_ARG_SPEC_SYNTH_LEN"] != "" {
if f == "" {
f = "LLAMA_ARG_SPEC_SYNTH_LEN"
}
notes = append(notes, "avertissement : "+f+" — le moteur accepte des jetons du brouillon AU HASARD, sans "+
"vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le.")
}
return notes
}
// contextShift dit si le moteur glissera le contexte, et qui l'a décidé (vide :
// son propre défaut). --context-shift et --no-context-shift peuvent cohabiter
// dans EXTRA_ARGS (preset copié, puis corrigé) ; comme llama-server, la
// dernière occurrence décide. Sans drapeau, la variable du moteur, puis son
// défaut — qui a changé : les llama-server d'avant --context-shift (mi-2025) ne
// connaissent que --no-context-shift et GLISSENT par défaut. Aide vide (moteur
// inconnu) : on ne suppose rien.
func contextShift(extra []string, argEnv map[string]string, help string) (on bool, src string) {
set := false
for _, a := range extra {
name, _, _ := strings.Cut(a, "=")
switch name {
case "--context-shift":
on, set = true, true
case "--no-context-shift":
on, set = false, true
}
}
if set {
return on, "EXTRA_ARGS"
}
if strings.Contains(help, "--no-context-shift") && !strings.Contains(help, "--context-shift") {
// Moteur ancien : seul LLAMA_ARG_NO_CONTEXT_SHIFT (lu comme un booléen
// vrai) l'arrête ; LLAMA_ARG_CONTEXT_SHIFT lui est inconnue.
if envTruthy(argEnv["LLAMA_ARG_NO_CONTEXT_SHIFT"]) {
return false, "LLAMA_ARG_NO_CONTEXT_SHIFT"
}
return true, ""
}
if envTruthy(argEnv["LLAMA_ARG_CONTEXT_SHIFT"]) {
return true, "LLAMA_ARG_CONTEXT_SHIFT"
}
return false, ""
}
@@ -0,0 +1,307 @@
package loki
import (
"context"
"encoding/json"
"go/ast"
"go/parser"
"go/token"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"reflect"
"strconv"
"strings"
"sync"
"testing"
)
func TestEffectiveKVTypes(t *testing.T) {
const ea, kv = "défini par EXTRA_ARGS", "KV_TYPE"
const env = "défini par LLAMA_ARG_CACHE_TYPE_K/V"
for _, c := range []struct {
name string
cfg map[string]string
extra string
argEnv map[string]string
k, v string
src string
}{
{"rien : défaut moteur", map[string]string{}, "", nil, "", "", ""},
{"KV_TYPE seul", map[string]string{"KV_TYPE": "q8_0"}, "", nil, "q8_0", "q8_0", kv},
{"EXTRA_ARGS l'emporte sur KV_TYPE", map[string]string{"KV_TYPE": "f16"},
"--cache-type-k q8_0 --cache-type-v q8_0", nil, "q8_0", "q8_0", ea},
{"formes courtes, la dernière gagne", map[string]string{},
"-ctk q4_0 -ctv q4_0 -ctk q8_0", nil, "q8_0", "q4_0", ea},
{"forme --flag=valeur", map[string]string{}, "--cache-type-v=q8_0", nil, "", "q8_0", ea},
{"K seul dans EXTRA_ARGS, V du preset", map[string]string{"KV_TYPE_V": "q4_0"},
"-ctk q8_0", nil, "q8_0", "q4_0", ea},
// Les variables passent AVANT la ligne de commande : seules, elles
// décident ; un -ctk venu de KV_TYPE les écrase.
{"variables seules", map[string]string{}, "",
map[string]string{"LLAMA_ARG_CACHE_TYPE_K": "q8_0", "LLAMA_ARG_CACHE_TYPE_V": "q8_0"}, "q8_0", "q8_0", env},
{"KV_TYPE_K écrase la variable K, la variable V reste", map[string]string{"KV_TYPE_K": "f16"}, "",
map[string]string{"LLAMA_ARG_CACHE_TYPE_K": "q4_0", "LLAMA_ARG_CACHE_TYPE_V": "q8_0"}, "f16", "q8_0", kv},
} {
t.Run(c.name, func(t *testing.T) {
k, v, src := effectiveKVTypes(c.cfg, splitArgs(c.extra), c.argEnv)
if k != c.k || v != c.v || src != c.src {
t.Fatalf("got %q/%q src=%q, want %q/%q src=%q", k, v, src, c.k, c.v, c.src)
}
})
}
}
func TestKVFidelityNote(t *testing.T) {
for _, c := range []struct {
name string
k, v string
src string
extra string
argEnv map[string]string
want []string // fragments attendus ; nil = aucune note
fit bool // la note doit parler de --fit (placement figé)
}{
{"défaut moteur : rien à dire", "", "", "", "", nil, nil, false},
{"f16 explicite : rien à dire", "f16", "f16", "KV_TYPE", "", nil, nil, false},
{"f32 ne perd rien", "f32", "f32", "KV_TYPE", "", nil, nil, false},
{"q8_0 : léger écart", "q8_0", "q8_0", "KV_TYPE", "", nil,
[]string{"q8_0/q8_0", "KV_TYPE", "modifie légèrement"}, false},
{"q4_0 : perte mesurable", "q4_0", "q4_0", "KV_TYPE", "", nil, []string{"perte mesurable"}, false},
{"mixte : le pire des deux", "q8_0", "q4_0", "KV_TYPE", "", nil, []string{"perte mesurable"}, false},
{"bf16 : numérique différente", "bf16", "bf16", "KV_TYPE", "", nil, []string{"mantisse"}, false},
{"V seul quantifié", "", "q8_0", "KV_TYPE", "", nil, []string{"f16/q8_0"}, false},
{"type inconnu : supposé altérer", "q3_k", "q3_k", "KV_TYPE", "", nil, []string{"modifie les sorties"}, false},
{"venu d'EXTRA_ARGS, placement figé : --fit ne compense pas", "q8_0", "q8_0", "défini par EXTRA_ARGS",
"-ot per_layer_token_embd.weight=CPU --n-cpu-moe 40 --cache-type-k q8_0", nil,
[]string{"défini par EXTRA_ARGS", "relever --n-cpu-moe"}, true},
// --n-cpu-moe 0 ne place rien : --fit tourne, rien à en dire.
{"--n-cpu-moe 0 : placement libre", "q8_0", "q8_0", "défini par EXTRA_ARGS",
"--n-cpu-moe 0 -ctk q8_0 -ctv q8_0", nil, []string{"q8_0/q8_0"}, false},
{"experts sur CPU par variable : placement figé aussi", "q8_0", "q8_0", "KV_TYPE", "",
map[string]string{"LLAMA_ARG_N_CPU_MOE": "30"}, []string{"relever --n-cpu-moe"}, true},
} {
t.Run(c.name, func(t *testing.T) {
got := kvFidelityNote(c.k, c.v, c.src, splitArgs(c.extra), c.argEnv)
if c.want == nil {
if got != "" {
t.Fatalf("note inattendue : %q", got)
}
return
}
for _, w := range c.want {
if !strings.Contains(got, w) {
t.Fatalf("note %q sans %q", got, w)
}
}
if strings.Contains(got, "--fit") != c.fit {
t.Fatalf("note %q : mention de --fit attendue = %v", got, c.fit)
}
})
}
}
func TestLossyCacheNotes(t *testing.T) {
// Aides des deux générations de llama-server : l'ancienne ne connaît que
// --no-context-shift et glisse par défaut, la récente ne glisse pas.
const helpShiftOld = "--no-context-shift disables context shift on infinite text generation (default: disabled)"
const helpShiftNew = "--context-shift, --no-context-shift whether to use context shift on infinite text generation (default: disabled)"
envOf := func(kv ...string) map[string]string {
m := map[string]string{}
for i := 0; i+1 < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
}
for _, c := range []struct {
name string
extra string
si serveSysInfo
want []string // un fragment par note attendue, dans l'ordre
}{
{"rien", "", serveSysInfo{}, nil},
{"--context-shift", "--context-shift", serveSysInfo{}, []string{"--context-shift (EXTRA_ARGS)"}},
{"--no-context-shift ne déclenche rien", "--no-context-shift", serveSysInfo{}, nil},
{"la dernière occurrence décide", "--context-shift --no-context-shift", serveSysInfo{}, nil},
{"--cache-reuse 256", "--cache-reuse 256", serveSysInfo{}, []string{"--cache-reuse 256 (EXTRA_ARGS)"}},
{"--cache-reuse=256", "--cache-reuse=256", serveSysInfo{}, []string{"--cache-reuse 256"}},
{"--cache-reuse 0 : désactivé", "--cache-reuse 0", serveSysInfo{}, nil},
{"--swa-full est sans perte", "--swa-full", serveSysInfo{}, nil},
{"les deux", "--context-shift --cache-reuse 64", serveSysInfo{}, []string{"jette", "--cache-reuse 64"}},
{"vision (MMPROJ) : llama.cpp les ignore, on le dit", "--context-shift --cache-reuse 64",
serveSysInfo{MMProj: "/m/mmproj.gguf"}, []string{"l'ignore", "l'ignore"}},
{"vision par --mmproj d'EXTRA_ARGS : pareil", "--mmproj mmproj-F16.gguf --cache-reuse 64",
serveSysInfo{}, []string{"l'ignore"}},
// Les variables du moteur comptent quand la ligne de commande se tait.
{"LLAMA_ARG_CACHE_REUSE", "", serveSysInfo{ArgEnv: envOf("LLAMA_ARG_CACHE_REUSE", "128")},
[]string{"--cache-reuse 128 (LLAMA_ARG_CACHE_REUSE)"}},
{"--cache-reuse 0 d'EXTRA_ARGS écrase la variable", "--cache-reuse 0",
serveSysInfo{ArgEnv: envOf("LLAMA_ARG_CACHE_REUSE", "128")}, nil},
{"LLAMA_ARG_CONTEXT_SHIFT=1", "", serveSysInfo{Help: helpShiftNew, ArgEnv: envOf("LLAMA_ARG_CONTEXT_SHIFT", "1")},
[]string{"(LLAMA_ARG_CONTEXT_SHIFT)"}},
{"moteur récent sans drapeau : rien", "", serveSysInfo{Help: helpShiftNew}, nil},
// Défaut d'un moteur ancien : il jette des jetons sans qu'on ait rien écrit.
{"moteur ancien : glisse par défaut, on le dit", "", serveSysInfo{Help: helpShiftOld},
[]string{"glisse le contexte par défaut"}},
{"moteur ancien, --no-context-shift : rien", "--no-context-shift", serveSysInfo{Help: helpShiftOld}, nil},
{"moteur ancien, LLAMA_ARG_NO_CONTEXT_SHIFT=1 : rien", "",
serveSysInfo{Help: helpShiftOld, ArgEnv: envOf("LLAMA_ARG_NO_CONTEXT_SHIFT", "1")}, nil},
{"moteur inconnu (aide vide) : on ne suppose rien", "", serveSysInfo{}, nil},
} {
t.Run(c.name, func(t *testing.T) {
got := lossyCacheNotes(splitArgs(c.extra), c.si)
if len(got) != len(c.want) {
t.Fatalf("notes = %q, en attendait %d", got, len(c.want))
}
for i, w := range c.want {
if !strings.Contains(got[i], w) {
t.Fatalf("note %q sans %q", got[i], w)
}
}
})
}
}
// Ce que buildServeArgs fait des garde-fous : JAMAIS de drapeau ajouté ou
// retiré, seulement des notes. Sans KV_TYPE, aucun -ctk/-ctv : le moteur garde
// son f16.
func TestBuildServeArgsFidelite(t *testing.T) {
const bin = "/opt/llama/llama-server"
si := serveSysInfo{Help: helpRecent, Model: "/models/m.gguf"}
for _, c := range []struct {
name string
cfg map[string]string
notes []string // fragments attendus parmi les notes ; nil = aucune note de fidélité
}{
{"KV_TYPE absent : ni -ctk ni -ctv, rien à dire", map[string]string{"NGL": "28"}, nil},
{"--swa-full : sans perte, silence", map[string]string{"NGL": "28", "EXTRA_ARGS": "--swa-full"}, nil},
{"cache quantifié par EXTRA_ARGS : vu", map[string]string{"NGL": "28",
"EXTRA_ARGS": "--cache-type-k q8_0 --cache-type-v q8_0"}, []string{"défini par EXTRA_ARGS"}},
{"--cache-reuse 256 : averti", map[string]string{"NGL": "28", "EXTRA_ARGS": "--cache-reuse 256"},
[]string{"--cache-reuse 256"}},
{"--context-shift : averti", map[string]string{"NGL": "28", "EXTRA_ARGS": "--context-shift"},
[]string{"--context-shift"}},
} {
t.Run(c.name, func(t *testing.T) {
extra := splitArgs(c.cfg["EXTRA_ARGS"])
args, _, notes := buildServeArgs(c.cfg, extra, bin, si)
if c.cfg["KV_TYPE"] == "" && !hasAnyFlag(extra, "-ctk", "-ctv", "--cache-type-k", "--cache-type-v") &&
hasAnyFlag(args, "-ctk", "-ctv", "--cache-type-k", "--cache-type-v") {
t.Fatalf("Loki a posé un type de cache de lui-même : %q", args)
}
// La ligne de commande se termine exactement par EXTRA_ARGS, intouché.
if tail := args[len(args)-len(extra):]; strings.Join(tail, " ") != strings.Join(extra, " ") {
t.Fatalf("EXTRA_ARGS modifié : %q", tail)
}
all := strings.Join(notes, "\n")
if c.notes == nil {
if strings.Contains(all, "cache KV") || strings.Contains(all, "avertissement") {
t.Fatalf("note de fidélité inattendue : %q", notes)
}
return
}
for _, w := range c.notes {
if !strings.Contains(all, w) {
t.Fatalf("notes %q sans %q", notes, w)
}
}
})
}
}
// Aucune requête de Loki vers le moteur ne coupe le cache de préfixe
// (cache_prompt:false) ni n'active la réutilisation approximative
// (n_cache_reuse). On vérifie les corps RÉELLEMENT envoyés par le chat et par
// la compaction, contre un faux llama-server local.
func TestRequetesSansCacheApproximatif(t *testing.T) {
testHome(t)
var mu sync.Mutex
var bodies []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
bodies = append(bodies, body)
mu.Unlock()
if body["stream"] == true {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
_, _ = w.Write([]byte(sseChunk("bonjour")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
return
}
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé"}}}})
}))
t.Cleanup(srv.Close)
u, err := url.Parse(srv.URL)
if err != nil {
t.Fatal(err)
}
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "salut"}}, 0.7, Caps{},
func(StreamEvent) bool { return true }); err != nil {
t.Fatalf("runChat : %v", err)
}
if _, err := summarizeTranscript(context.Background(), "user: bonjour\nassistant: salut"); err != nil {
t.Fatalf("compaction : %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(bodies) < 2 {
t.Fatalf("%d requête(s) reçue(s), attendu chat + compaction", len(bodies))
}
for _, b := range bodies {
if v, ok := b["cache_prompt"]; ok && v != true {
t.Errorf("cache_prompt=%v envoyé au moteur : le préfixe serait recalculé ou faussé", v)
}
if _, ok := b["n_cache_reuse"]; ok {
t.Errorf("n_cache_reuse envoyé au moteur : réutilisation approximative du cache")
}
}
}
// Filet sur tout le paquet : les clés cache_prompt et n_cache_reuse n'existent
// dans le code QUE pour le benchmark, qui mesure le prefill et doit donc couper
// le cache. Lecture de l'arbre syntaxique (pas du texte) : un commentaire qui
// les mentionne ne compte pas, un nouveau fichier est couvert d'office.
func TestCleCacheReserveeAuBench(t *testing.T) {
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
allowed := map[string]bool{"llm_bench.go": true}
fset := token.NewFileSet()
for _, f := range files {
if strings.HasSuffix(f, "_test.go") || allowed[f] {
continue
}
file, err := parser.ParseFile(fset, f, nil, parser.SkipObjectResolution)
if err != nil {
t.Fatal(err)
}
banned := func(s string) bool { return s == "cache_prompt" || s == "n_cache_reuse" }
ast.Inspect(file, func(n ast.Node) bool {
// Un champ de structure sérialisé (étiquette json:"cache_prompt")
// enverrait la clé aussi sûrement qu'une map : on lit les étiquettes.
if f, ok := n.(*ast.Field); ok && f.Tag != nil {
if tag, err := strconv.Unquote(f.Tag.Value); err == nil {
if name, _, _ := strings.Cut(reflect.StructTag(tag).Get("json"), ","); banned(name) {
t.Errorf("%s : champ %q hors du benchmark", fset.Position(f.Pos()), name)
}
}
return true
}
lit, ok := n.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
if s, err := strconv.Unquote(lit.Value); err == nil && banned(s) {
t.Errorf("%s : clé %q hors du benchmark", fset.Position(lit.Pos()), s)
}
return true
})
}
}
+50 -5
View File
@@ -1,8 +1,10 @@
package loki
import (
"net"
"reflect"
"testing"
"time"
)
func TestHasAnyFlag(t *testing.T) {
@@ -31,16 +33,25 @@ func TestNormalizeLoadFlags(t *testing.T) {
supports bool
want []string
}{
{"moteur ancien : rien ne bouge", []string{"--mlock", "--no-mmap"}, false,
{"moteur ancien : anciens drapeaux intacts", []string{"--mlock", "--no-mmap"}, false,
[]string{"--mlock", "--no-mmap"}},
{"mlock traduit", []string{"--mlock", "-fa"}, true,
[]string{"-fa", "--load-mode", "mlock"}},
// L'ancien --mlock gardait le mmap : « mlock » de llama.cpp le coupe.
{"mlock seul → mmap+mlock", []string{"--mlock", "-fa"}, true,
[]string{"-fa", "--load-mode", "mmap+mlock"}},
{"no-mmap traduit", []string{"--no-mmap"}, true,
[]string{"--load-mode", "none"}},
{"mmap traduit", []string{"--mmap"}, true,
[]string{"--load-mode", "mmap"}},
{"mlock l'emporte sur no-mmap", []string{"--no-mmap", "--mlock"}, true,
{"mlock + no-mmap → mlock", []string{"--no-mmap", "--mlock"}, true,
[]string{"--load-mode", "mlock"}},
{"moteur ancien : none retraduit", []string{"-fa", "--load-mode", "none"}, false,
[]string{"-fa", "--no-mmap"}},
{"moteur ancien : mlock retraduit", []string{"--load-mode", "mlock"}, false,
[]string{"--mlock", "--no-mmap"}},
{"moteur ancien : mmap+mlock retraduit", []string{"-lm", "mmap+mlock"}, false,
[]string{"--mlock"}},
{"moteur ancien : dio abandonné", []string{"--load-mode=dio", "-fa"}, false,
[]string{"-fa"}},
{"--load-mode explicite gagne, vieux drapeaux retirés",
[]string{"--mlock", "--load-mode", "dio"}, true,
[]string{"--load-mode", "dio"}},
@@ -49,10 +60,15 @@ func TestNormalizeLoadFlags(t *testing.T) {
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := normalizeLoadFlags(c.in, c.supports)
got, note := normalizeLoadFlags(c.in, c.supports)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("got %q, want %q", got, c.want)
}
// Seul « dio » abandonné sur un moteur ancien se signale : les autres
// traductions gardent le sens demandé.
if wantNote := c.name == "moteur ancien : dio abandonné"; (note != "") != wantNote {
t.Fatalf("note = %q, attendue : %v", note, wantNote)
}
})
}
}
@@ -91,3 +107,32 @@ func TestNGLArgs(t *testing.T) {
})
}
}
func TestArgValueEtPortLibre(t *testing.T) {
if got := argValue([]string{"--port", "8080", "-c", "1", "--port", "9000"}, "--port"); got != "9000" {
t.Fatalf("la dernière occurrence doit gagner, got %q", got)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
_, port, _ := net.SplitHostPort(ln.Addr().String())
if waitPortFree("0.0.0.0", port, 600*time.Millisecond) == nil {
t.Fatal("un port occupé doit être refusé")
}
ln.Close()
if err := waitPortFree("0.0.0.0", port, time.Second); err != nil {
t.Fatalf("un port libre doit passer : %v", err)
}
}
func TestSlowKV(t *testing.T) {
for _, c := range []struct {
k, v string
lent bool
}{{"", "", false}, {"q8_0", "q8_0", false}, {"q4_0", "q4_0", false}, {"q8_0", "q4_0", true}, {"q5_1", "q5_1", true}} {
if slowKV(c.k, c.v) != c.lent {
t.Errorf("%s/%s : lent attendu %v", c.k, c.v, c.lent)
}
}
}
+628
View File
@@ -0,0 +1,628 @@
package loki
import (
"fmt"
"os"
"regexp"
"strconv"
"strings"
)
// Experts MoE et chargement du modèle : des avis et un garde-fou, jamais de
// réécriture silencieuse du preset.
//
// moeNotes au lancement et dans l'éditeur : experts placés à la
// main (--fit ne les place pas), UBATCH conseillé quand
// --fit laisse des experts en RAM, moteur sans --fit
// loadModeRisk --load-mode none/mlock/dio/mmap+mlock (ou --no-mmap,
// --mlock) avec plus de poids en RAM que la machine n'en
// a : avertissement ; refus seulement si l'échec est
// certain, LOAD_GUARD=off pour passer outre
// autoPlacementPreset COPIE d'un preset MoE placé à la main, en placement
// automatique ; l'original n'est pas touché
//
// Rien de tout cela ne change ce que calcule le modèle : placement des poids
// et découpage des lots seulement, mêmes types de cache, même contexte.
// otPatterns : chaque motif « regex=tampon » des -ot / --override-tensor (et
// de LLAMA_ARG_OVERRIDE_TENSOR) ; llama.cpp en accepte plusieurs par drapeau,
// séparés par des virgules.
func otPatterns(extra []string, argEnv map[string]string) []string {
vals := flagValues(extra, "-ot", "--override-tensor")
if v := argEnv["LLAMA_ARG_OVERRIDE_TENSOR"]; v != "" {
vals = append(vals, v)
}
var out []string
for _, v := range vals {
for _, el := range strings.Split(v, ",") {
if el = strings.TrimSpace(el); el != "" {
out = append(out, el)
}
}
}
return out
}
// otTargetsExperts : le motif vise-t-il les tenseurs d'experts (ffn_*_exps,
// ffn_*_chexps) ? Un motif qui ne vise que la couche d'entrée
// (per_layer_token_embd) n'en est pas.
func otTargetsExperts(pattern string) bool {
return strings.Contains(strings.ToLower(pattern), "exps")
}
// otOnCPU : le tampon du motif est-il la RAM hôte (CPU, CPU_REPACK…) ?
func otOnCPU(el string) bool {
_, buft, ok := strings.Cut(el, "=")
return ok && strings.HasPrefix(strings.ToUpper(strings.TrimSpace(buft)), "CPU")
}
// cpuExperts dit comment des experts MoE sont envoyés en RAM à la main, ou
// vide. Plus étroit que cpuWeights : un -ot ne compte que s'il vise les
// experts, et le cache KV ou un NGL partiel n'en sont pas.
func cpuExperts(extra []string, argEnv map[string]string) string {
for _, el := range otPatterns(extra, argEnv) {
pat, _, _ := strings.Cut(el, "=")
if otTargetsExperts(pat) && otOnCPU(el) {
return "-ot experts=CPU"
}
}
if hasAnyFlag(extra, "--cpu-moe", "-cmoe") || envTruthy(argEnv["LLAMA_ARG_CPU_MOE"]) {
return "--cpu-moe"
}
for _, v := range append(flagValues(extra, "-ncmoe", "--n-cpu-moe"), argEnv["LLAMA_ARG_N_CPU_MOE"]) {
if v = strings.TrimSpace(v); v != "" && v != "0" {
return "--n-cpu-moe " + v
}
}
return ""
}
// fitFlagRe : « --fit » lui-même dans l'aide, pas seulement --fit-target.
var fitFlagRe = regexp.MustCompile(`(^|[\s,])--fit([\s,=\[]|$)`)
// helpSupportsFit : ce moteur sait-il placer les poids tout seul (--fit) ?
func helpSupportsFit(help string) bool { return fitFlagRe.MatchString(help) }
// serveUBatch : le micro-lot que verra le moteur (-ub d'EXTRA_ARGS, sinon
// UBATCH, sinon le défaut de Loki). 0 = illisible.
func serveUBatch(cfg map[string]string, extra []string) int {
s := flagValue(extra, "-ub", "--ubatch-size")
if s == "" {
s = strings.TrimSpace(cfg["UBATCH"])
}
if s == "" {
s = "512"
}
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n < 0 {
return 0
}
return n
}
// gibText : des Mio en « 12,3 Gio ».
func gibText(mib int64) string {
return strings.Replace(strconv.FormatFloat(float64(mib)/1024, 'f', 1, 64), ".", ",", 1) + " Gio"
}
// moeNotes : ce qu'il faut savoir du placement d'un MoE, pour ce lancement.
// Fonction pure, comme nglArgs : des notes seulement, aucun drapeau.
//
// - experts placés à la main (-ot visant les experts, --n-cpu-moe,
// --cpu-moe) : --fit ne les place pas. Monter UBATCH seul y serait un
// piège : le tampon de calcul grossit sur CHAQUE carte, sans personne pour
// rééquilibrer, et le moteur finit en « cudaMalloc failed » ;
// - placement auto, modèle plus gros que la VRAM : --fit laisse des experts
// en RAM, recopiés vers le GPU à chaque micro-lot du prefill — un UBATCH de
// 2048 ou plus en paie quatre fois moins. --fit, lui, retient de la place
// pour ce tampon plus gros ;
// - moteur sans --fit (ancien, fork) : rien ne sort les experts du GPU,
// garder --n-cpu-moe ;
// - --fit coupé par un autre -ot : même chose.
//
// Muette sur un modèle dense, un preset externe, une VRAM inconnue (macOS,
// --device, AMD) ou une aide du moteur illisible.
func moeNotes(cfg map[string]string, extra []string, si serveSysInfo) []string {
if isExternalConfig(cfg) || si.GGUF == nil || si.GGUF.ExpertCount <= 0 {
return nil
}
ub := serveUBatch(cfg, extra)
if r := cpuExperts(extra, si.ArgEnv); r != "" {
n := "experts placés à la main (" + r + ") : --fit ne les place pas"
if ub > 0 && ub <= 512 {
n += " ; UBATCH plus grand = tampon de calcul plus gros sur chaque GPU : monte --n-cpu-moe d'abord, " +
"ou passe au placement auto (--fit, « Dupliquer en placement auto » dans l'éditeur)"
}
return []string{n}
}
modelMiB := si.ModelBytes >> 20
if modelMiB <= 0 || si.VRAMMiB <= 0 || si.UnifiedMem || strings.TrimSpace(si.Help) == "" {
return nil
}
cards := int64(si.GPUs)
if cards < 1 {
cards = 1
}
if modelMiB <= si.VRAMMiB-fitTargetMin*cards {
return nil // tout tient sur GPU : pas d'expert recopié
}
sizes := "modèle " + gibText(modelMiB) + ", VRAM " + gibText(si.VRAMMiB)
if !helpSupportsFit(si.Help) {
// Un NGL chiffré sous 999 laisse déjà des couches (et leurs experts) au
// CPU : l'utilisateur a tranché.
if n, err := strconv.Atoi(strings.TrimSpace(firstNonEmpty(flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers"), cfg["NGL"]))); err == nil && n < 999 {
return nil
}
return []string{"MoE plus gros que la VRAM (" + sizes + ") : ce moteur ne place pas les experts (pas de --fit) — " +
"garde --n-cpu-moe"}
}
if why := fitBlocker(cfg, extra, si); why != "" {
if why == "surcharge de tenseurs" {
return []string{"MoE plus gros que la VRAM (" + sizes + ") et --fit coupé par un -ot qui ne vise pas les " +
"experts : rien ne les sort du GPU — --n-cpu-moe, ou retire ce -ot"}
}
return nil
}
if ub > 0 && ub <= 512 {
return []string{"UBATCH 2048+ recommandé : --fit laisse des experts en RAM (" + sizes + "), recopiés vers le " +
"GPU à chaque micro-lot du prefill ; BATCH 4096 avec — à mesurer (bench complet)"}
}
return nil
}
// firstNonEmpty : la première valeur non vide.
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// loadArgEnv : les variables qui règlent le chargement sans drapeau.
// LLAMA_ARG_RPC n'en est pas une, mais dit que des poids partent sur des
// machines distantes (loadModeRisk).
var loadArgEnv = []string{"LLAMA_ARG_LOAD_MODE", "LLAMA_ARG_MLOCK", "LLAMA_ARG_NO_MMAP", "LLAMA_ARG_RPC"}
// probeLoadEnv relève ces variables. Appelée après probeServeGPUs, qui
// remet ArgEnv à zéro.
func probeLoadEnv(si *serveSysInfo) {
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range loadArgEnv {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
si.ArgEnv[k] = v
}
}
}
// residentLoadMode : le mode de chargement qui garde ENTIÈREMENT en RAM les
// poids laissés au CPU, tel qu'écrit (« --load-mode none », « --no-mmap »…),
// ou vide. mmap (et auto) laisse le système relire les pages au besoin : lent
// quand la RAM manque, jamais fatal. none et dio copient les poids en mémoire
// anonyme, mlock et mmap+mlock les verrouillent. Lu sur la ligne finale
// (normalizeLoadFlags a déjà traduit l'une ou l'autre forme), puis dans les
// LLAMA_ARG_* si elle se tait.
func residentLoadMode(args []string, argEnv map[string]string) string {
mode := strings.ToLower(strings.TrimSpace(flagValue(args, "--load-mode", "-lm")))
mlock, nommap := hasAnyFlag(args, "--mlock"), hasAnyFlag(args, "--no-mmap")
if mode == "" && !mlock && !nommap {
mode = strings.ToLower(strings.TrimSpace(argEnv["LLAMA_ARG_LOAD_MODE"]))
if mode == "" {
mlock, nommap = envTruthy(argEnv["LLAMA_ARG_MLOCK"]), envTruthy(argEnv["LLAMA_ARG_NO_MMAP"])
}
}
switch mode {
case "none", "mlock", "dio", "mmap+mlock":
return "--load-mode " + mode
}
switch {
case mlock && nommap:
return "--mlock --no-mmap"
case mlock:
return "--mlock"
case nommap:
return "--no-mmap"
}
return ""
}
// loadGuardMarker : la fin du message de refus, que l'interface reconnaît
// dans le journal du service (modelLoadError).
const loadGuardMarker = "LOAD_GUARD=off pour lancer quand même"
// loadGuardOff : LOAD_GUARD=off, la clé d'échappement du refus.
func loadGuardOff(cfg map[string]string) bool {
switch strings.ToLower(strings.TrimSpace(cfg["LOAD_GUARD"])) {
case "off", "0", "false", "non", "no":
return true
}
return false
}
// loadGuardExitStatus : le code de sortie de « loki serve » quand LOAD_GUARD
// refuse. Le refus est certain et ne dépend que du preset et de la machine :
// relancer ne changerait rien. Sous systemd, Restart=on-failure relançait
// pourtant toutes les trois secondes, sans fin ; l'unité écrite par
// « loki install » porte donc RestartPreventExitStatus pour ce code-là
// (EX_CONFIG de sysexits.h : erreur de configuration). Le conteneur, lui, ne
// relance jamais le moteur de lui-même.
const loadGuardExitStatus = 78
// unitPreventsRefusalRestart : l'unité systemd (son texte) s'abstient-elle de
// relancer sur loadGuardExitStatus ? Une unité écrite par une version
// d'avant ne le fait pas.
func unitPreventsRefusalRestart(unit string) bool {
for _, line := range strings.Split(unit, "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok || strings.TrimSpace(k) != "RestartPreventExitStatus" {
continue
}
for _, f := range strings.Fields(v) {
if f == strconv.Itoa(loadGuardExitStatus) {
return true
}
}
}
return false
}
// loadGuardRefusal : l'erreur rendue par cmdServe pour un refus, avec son code
// de sortie. Un superviseur qui relancerait quand même sur ce code (unité
// systemd d'une version d'avant, launchd, qui relance toute sortie non nulle)
// reçoit 0 : une sortie « propre » n'est pas relancée, et la raison reste en
// clair dans le journal, où l'interface la reconnaît (modelLoadError). Le
// moteur d'essai de l'optimiseur garde le vrai code : c'est lui qui dit
// l'échec à « loki tune ».
func loadGuardRefusal(refuse string, trial bool) error {
code := loadGuardExitStatus
if !trial {
if restarts, hint := supervisorRestartsRefusal(); restarts {
code = 0
if hint != "" {
fmt.Fprintln(os.Stderr, "[loki serve] "+hint)
}
}
}
return &exitStatusError{code: code, err: fmt.Errorf("%s", refuse)}
}
// loadModeRisk : un mode de chargement résident avec trop de poids en RAM.
// Fonction pure ; args est la ligne finale (ou EXTRA_ARGS normalisé).
//
// llama.cpp #26110 : un modèle de 65 Go sur 60 Go de RAM, --load-mode none →
// swap, décodage de 25 à 7,5 t/s ; mlock → processus tué faute de mémoire.
// Seuls comptent les poids qui RESTENT en RAM : ceux qui partent en VRAM n'y
// séjournent pas. On en connaît une borne basse — modèle moins VRAM totale
// (tout le modèle sur macOS, mémoire unifiée) — et une estimation haute quand
// la VRAM est inconnue (le modèle entier).
//
// - refuse : la borne basse dépasse 90 % de la RAM effective (limite du
// conteneur comprise) — l'échec est certain. LOAD_GUARD=off lève le refus ;
// - warn : l'estimation dépasse 80 % — on prévient, on lance.
//
// Un refus fait sortir « loki serve » en erreur, comme un modèle introuvable :
// mieux vaut la raison en clair qu'un moteur tué en boucle par le noyau. Le code
// de sortie (loadGuardRefusal) empêche le superviseur de relancer en boucle.
func loadModeRisk(cfg map[string]string, args []string, si serveSysInfo) (warn, refuse string) {
if isExternalConfig(cfg) {
return "", ""
}
// Un moteur qui connaît --load-mode n'a plus LLAMA_ARG_MLOCK ni
// LLAMA_ARG_NO_MMAP : restées dans l'environnement, elles n'ont aucun effet
// et ne doivent pas faire refuser un lancement en mmap.
env := si.ArgEnv
if helpSupportsLoadMode(si.Help) && (env["LLAMA_ARG_MLOCK"] != "" || env["LLAMA_ARG_NO_MMAP"] != "") {
env = map[string]string{"LLAMA_ARG_LOAD_MODE": si.ArgEnv["LLAMA_ARG_LOAD_MODE"]}
}
mode := residentLoadMode(args, env)
modelMiB := si.ModelBytes >> 20
if mode == "" || modelMiB <= 0 || si.RAMMiB <= 0 {
return "", ""
}
// Serveurs RPC : une part des poids part sur d'autres machines, la VRAM
// locale ne borne plus rien — inconnu, donc un avis au plus, jamais un refus.
remote := hasAnyFlag(args, "--rpc") || strings.TrimSpace(si.ArgEnv["LLAMA_ARG_RPC"]) != ""
low, est, upTo, unsure := int64(0), modelMiB, true, false
switch {
case remote:
case si.UnifiedMem:
low, est, upTo = modelMiB, modelMiB, false
case si.EngineVRAMMiB > 0:
// Les cartes que le moteur liste lui-même : rien au-delà ne peut
// recevoir de poids, la borne basse est sûre.
low = max(modelMiB-si.EngineVRAMMiB, 0)
est, upTo = low, false
case si.VRAMMiB > 0:
// nvidia-smi seulement : un moteur Vulkan sur cartes mixtes NVIDIA +
// AMD en utilise davantage, un moteur ROCm d'autres. Une estimation,
// jamais une borne : un avis au plus (voir probeLoadDevices).
est, upTo, unsure = max(modelMiB-si.VRAMMiB, 0), false, true
}
ram := si.RAMMiB
what := "environ "
if upTo {
what = "jusqu'à "
}
if unsure && est*10 > ram*9 && !loadGuardOff(cfg) {
return fmt.Sprintf("%s : environ %s de poids en RAM (modèle %s, VRAM NVIDIA %s) pour %s — échec probable ; "+
"refus seulement si les cartes listées par le moteur le confirment, lancement sinon ; "+
"--load-mode mmap est plus sûr", mode, gibText(est), gibText(modelMiB), gibText(si.VRAMMiB), gibText(ram)), ""
}
if low*10 > ram*9 {
msg := fmt.Sprintf("%s : au moins %s de poids restent en RAM (modèle %s) pour %s de RAM, limite du conteneur "+
"comprise — échec certain : swap et décodage effondré, ou processus tué (llama.cpp #26110). "+
"--load-mode mmap laisse le système relire les pages au besoin", mode, gibText(low), gibText(modelMiB), gibText(ram))
if loadGuardOff(cfg) {
return msg + " ; LOAD_GUARD=off : lancé quand même", ""
}
return "", msg + " ; " + loadGuardMarker
}
if est*10 > ram*8 {
return fmt.Sprintf("%s : %s%s de poids en RAM pour %s — au-delà, swap (décodage de 25 à 7,5 t/s dans "+
"llama.cpp #26110) ou processus tué ; --load-mode mmap est plus sûr", mode, what, gibText(est), gibText(ram)), ""
}
return "", ""
}
// loadGuardNeedsDevices : un refus se profile sur la seule VRAM NVIDIA
// (nvidia-smi) ? Il ne sera prononcé que sur les cartes du moteur lui-même —
// à lire d'abord (probeLoadDevices). Fonction pure.
func loadGuardNeedsDevices(cfg map[string]string, args []string, si serveSysInfo) bool {
if si.EngineVRAMMiB > 0 || si.VRAMMiB <= 0 {
return false
}
si.EngineVRAMMiB = si.VRAMMiB
_, refuse := loadModeRisk(cfg, args, si)
return refuse != ""
}
// engineDevsVRAM : la VRAM des cartes listées par le moteur, toutes
// additionnées — sans --device, llama.cpp ne place rien ailleurs, et une carte
// qu'il listerait sans s'en servir (iGPU) ne fait que rendre le refus plus
// rare. 0 (inconnu) si la liste est vide ou si une carte annonce 0 Mio (déjà
// pleine : lecture transitoire, voir handleBackendDevices).
func engineDevsVRAM(devs []splitDev) int64 {
var total int64
for _, d := range devs {
if d.TotalMiB <= 0 {
return 0
}
total += d.TotalMiB
}
return total
}
// probeLoadDevices : seulement quand loadGuardNeedsDevices — un mode de
// chargement résident qu'on s'apprête à refuser. Les cartes de SPLIT_MODE si
// elles sont déjà lues, sinon --list-devices dans l'environnement du
// lancement (sélection GPU comprise). Échec de lecture : EngineVRAMMiB reste
// à 0, loadModeRisk avertit sans refuser. args : EXTRA_ARGS normalisé.
func probeLoadDevices(cfg map[string]string, args []string, bin string, si *serveSysInfo) {
if !loadGuardNeedsDevices(cfg, args, *si) {
return
}
devs := si.SplitDevs
if len(devs) == 0 {
devs = listEngineDevices(bin)
}
si.EngineVRAMMiB = engineDevsVRAM(devs)
}
// previewServeHelp : l'aide supposée d'un moteur récent, pour les aperçus de
// l'éditeur (le process web ne lance pas le moteur pour lire son aide). Le
// lancement, lui, décide sur la vraie aide.
const previewServeHelp = "--load-mode --fit [on|off] --fit-target"
// moePreview : les avis de moeNotes et loadModeRisk pour l'éditeur, calculés
// comme au lancement, sur un moteur supposé récent.
func moePreview(content string) (notes []string, load string) {
cfg := parseEnv(content)
if isExternalConfig(cfg) {
return nil, ""
}
si := serveSysInfo{Help: previewServeHelp, ArgEnv: map[string]string{}}
for _, k := range append(append(append([]string{}, serveArgEnv...), fitArgEnv...), loadArgEnv...) {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
if m := strings.TrimSpace(cfg["MODEL"]); m != "" {
if p, err := resolveServeModelPath(m); err == nil {
si.Model = p
}
}
if si.Model == "" {
return nil, ""
}
extra := splitArgs(cfg["EXTRA_ARGS"])
probeCacheRAM(cfg, extra, &si)
norm, _ := normalizeLoadFlags(extra, true)
// Les cartes du moteur, sans le lancer : la dernière liste complète qu'il a
// donnée à l'éditeur (devices.json). Absente : un avis, pas un refus.
if loadGuardNeedsDevices(cfg, norm, si) {
bin := prebuiltResolveBin(firstNonEmpty(cfg["BIN"], ReadConfig()["BIN"]))
if list, ok := devPersistGet(bin + "\x00" + cfg["CUDA_VISIBLE_DEVICES"]); ok {
si.EngineVRAMMiB = engineDevsVRAM(splitDevsFrom(list))
}
}
warn, refuse := loadModeRisk(cfg, norm, si)
if refuse != "" {
warn = "refusé au lancement — " + refuse
}
return moeNotes(cfg, extra, si), warn
}
// autoPlacementPreset prépare la COPIE d'un preset MoE placé à la main, en
// placement automatique. Fonction pure : elle rend le nouveau contenu et la
// liste de ce qui change ; l'appelant l'enregistre sous un nouveau nom, et le
// preset d'origine reste tel quel — le repli si --fit échoue.
//
// Dans la copie :
//
// - retirés d'EXTRA_ARGS : les -ot qui visent les experts, un -ot qui
// n'envoie au CPU que per_layer_token_embd (couche d'entrée : llama.cpp la
// laisse toujours au CPU, le motif est redondant mais coupe --fit),
// --n-cpu-moe, --cpu-moe, -ngl, --tensor-split, --fit off, -b/-ub et les
// drapeaux de chargement ;
// - NGL retiré (Loki écrit -ngl auto sur un moteur qui sait placer),
// UBATCH=2048, BATCH=4096, « --load-mode mmap » (rien sur un moteur
// ancien, qui charge déjà ainsi) ;
// - CTX, cache KV, échantillonnage, raisonnement : intouchés.
//
// Refus (err) quand la copie ne tournerait pas en --fit — un autre -ot, -sm
// row/tensor, SPLIT_MODE=tensor, --n-cpu-ffn — ou quand CTX vaut 0 (--fit
// pourrait réduire le contexte), et quand il n'y a rien à migrer. Jamais de
// --tensor-read-lazy ni --lazy-mode : leur nom change d'un moteur à l'autre.
func autoPlacementPreset(content string) (out string, changes []string, err error) {
cfg := parseEnv(content)
if isExternalConfig(cfg) {
return "", nil, fmt.Errorf("preset externe : le modèle tourne ailleurs")
}
extra := splitArgs(cfg["EXTRA_ARGS"])
var kept []string
migrated := false
drop := func(s string) { changes = append(changes, "retiré : "+s) }
for i := 0; i < len(extra); i++ {
a := extra[i]
name, val, hasEq := strings.Cut(a, "=")
value := func() string {
if hasEq {
return val
}
if i+1 < len(extra) {
i++
return extra[i]
}
return ""
}
switch name {
case "-ot", "--override-tensor":
v := value()
var rest []string
for _, el := range strings.Split(v, ",") {
el = strings.TrimSpace(el)
pat, _, _ := strings.Cut(el, "=")
switch {
case el == "":
case otTargetsExperts(pat):
drop(name + " " + el + " (experts : --fit les place)")
migrated = true
case strings.Contains(strings.ToLower(pat), "per_layer_token_embd") && otOnCPU(el):
drop(name + " " + el + " (couche d'entrée, toujours au CPU)")
default:
rest = append(rest, el)
}
}
if len(rest) > 0 {
kept = append(kept, name, strings.Join(rest, ","))
}
case "-ncmoe", "--n-cpu-moe":
v := value()
drop(name + " " + v)
if strings.TrimSpace(v) != "0" {
migrated = true
}
case "-cmoe", "--cpu-moe":
drop(name)
migrated = true
case "-ngl", "--n-gpu-layers", "--gpu-layers", "-ts", "--tensor-split",
"-b", "--batch-size", "-ub", "--ubatch-size", "--load-mode", "-lm":
drop(name + " " + value())
case "--mlock", "--no-mmap", "--mmap":
drop(name)
case "-fit", "--fit":
v := value()
switch strings.ToLower(strings.TrimSpace(v)) {
case "off", "disabled", "false", "0":
drop(name + " " + v)
default:
kept = append(kept, name, v)
}
default:
kept = append(kept, a)
}
}
if !migrated {
return "", nil, fmt.Errorf("rien à migrer : ce preset ne place pas d'experts à la main (-ot …exps…, --n-cpu-moe, --cpu-moe)")
}
kept = append(kept, "--load-mode", "mmap")
nc := map[string]string{}
for k, v := range cfg {
nc[k] = v
}
nc["EXTRA_ARGS"] = joinArgs(kept)
nc["UBATCH"], nc["BATCH"] = "2048", "4096"
delete(nc, "NGL")
if ctx, ok := ctxFixed(nc, kept); !ok {
return "", nil, fmt.Errorf("contexte « %s » non chiffré : --fit pourrait le réduire pour tenir — donne un CTX chiffré d'abord", ctx)
}
if splitModeKey(nc) == "tensor" {
return "", nil, fmt.Errorf("SPLIT_MODE=tensor coupe --fit : retire-le d'abord")
}
if why := fitBlocker(nc, kept, serveSysInfo{Help: previewServeHelp, ArgEnv: map[string]string{}}); why != "" {
return "", nil, fmt.Errorf("--fit resterait inactif (%s) : retire-le à la main d'abord", why)
}
if ngl := strings.TrimSpace(cfg["NGL"]); ngl != "" {
changes = append(changes, "NGL="+ngl+" retiré : le moteur place les couches (-ngl auto)")
}
changes = append(changes, "UBATCH=2048, BATCH=4096 (avant : "+firstNonEmpty(cfg["UBATCH"], "512")+" / "+
firstNonEmpty(cfg["BATCH"], "2048")+")", "--load-mode mmap")
ctx, _ := ctxFixed(nc, kept)
changes = append(changes, "CTX conservé : "+ctx)
if k, v := serveKVTypes(nc); k != "" || v != "" ||
flagValue(kept, "-ctk", "--cache-type-k") != "" || flagValue(kept, "-ctv", "--cache-type-v") != "" {
changes = append(changes, "cache KV conservé tel quel — garde-le identique pour comparer les deux presets")
}
out = content
out = presetSetKey(out, "EXTRA_ARGS", nc["EXTRA_ARGS"])
out = presetSetKey(out, "UBATCH", "2048")
out = presetSetKey(out, "BATCH", "4096")
out = presetSetKey(out, "NGL", "")
return out, changes, nil
}
// joinArgs : l'inverse de splitArgs — un argument qui contient un blanc est
// entouré d'apostrophes (de guillemets s'il en contient une), pour être relu
// d'un seul tenant.
func joinArgs(args []string) string {
parts := make([]string, len(args))
for i, a := range args {
switch {
case a == "":
parts[i] = `""`
case strings.ContainsAny(a, " \t"):
if strings.Contains(a, "'") {
parts[i] = `"` + a + `"`
} else {
parts[i] = "'" + a + "'"
}
default:
parts[i] = a
}
}
return strings.Join(parts, " ")
}
// presetSetKey remplace les lignes KEY=… d'un preset (toutes : parseEnv
// retient la dernière, une ancienne restée plus bas l'emporterait), l'ajoute à
// la fin si elle manque, les retire si val est vide. Le reste du fichier —
// commentaires, ordre, NAME — ne bouge pas : c'est l'équivalent de cfgWriteKey
// côté éditeur.
func presetSetKey(content, key, val string) string {
re := regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?` + regexp.QuoteMeta(key) + `[ \t]*=.*$`)
if val == "" {
return regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?`+regexp.QuoteMeta(key)+`[ \t]*=.*(?:\n|$)`).
ReplaceAllLiteralString(content, "")
}
line := key + "=" + quoteValue(val)
if re.MatchString(content) {
return re.ReplaceAllLiteralString(content, line)
}
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
return content + line + "\n"
}
+389
View File
@@ -0,0 +1,389 @@
package loki
import (
"errors"
"fmt"
"reflect"
"strings"
"testing"
)
const gib = int64(1) << 30
// Un -ot ne compte comme experts en RAM que s'il vise les experts ET la RAM ;
// celui de la couche d'entrée (per_layer_token_embd) n'en est pas.
func TestCpuExperts(t *testing.T) {
cases := []struct {
extra string
env map[string]string
want bool
}{
{`-ot per_layer_token_embd.weight=CPU`, nil, false},
{`-ot "blk\..*\.ffn_.*_exps\.=CPU"`, nil, true},
{`--override-tensor=per_layer_token_embd.weight=CPU,\.ffn_(up|down)_exps=CPU`, nil, true},
{`-ot \.ffn_.*_exps=CUDA1`, nil, false},
{`-cmoe`, nil, true},
{`--n-cpu-moe 0`, nil, false},
{`--n-cpu-moe 40`, nil, true},
{`-ncmoe=12`, nil, true},
{``, map[string]string{"LLAMA_ARG_N_CPU_MOE": "20"}, true},
{``, map[string]string{"LLAMA_ARG_CPU_MOE": "1"}, true},
{`-nkvo -ngl 20`, nil, false},
}
for _, c := range cases {
if got := cpuExperts(splitArgs(c.extra), c.env) != ""; got != c.want {
t.Errorf("cpuExperts(%q, %v) = %v, want %v", c.extra, c.env, got, c.want)
}
}
}
// moeNotes : les avis, au bon endroit et seulement là.
func TestMoeNotes(t *testing.T) {
moe := &GGUFInfo{ExpertCount: 128, BlockCount: 48}
big := serveSysInfo{Help: helpFit, GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10, GPUs: 2}
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want string // sous-chaîne attendue ; vide = aucune note
}{
{name: "modèle dense : rien",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 40"},
si: serveSysInfo{Help: helpFit, GGUF: &GGUFInfo{BlockCount: 64}, ModelBytes: 82 * gib, VRAMMiB: 28 << 10}},
{name: "placement manuel, UBATCH 512 : ne pas monter UBATCH seul",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 40"}, si: big, want: "monte --n-cpu-moe d'abord"},
{name: "placement manuel, UBATCH 2048 : --fit ne les place pas, sans conseil de lot",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 40", "UBATCH": "2048"}, si: big, want: "--fit ne les place pas"},
{name: "placement auto, MoE plus gros que la VRAM, UBATCH 512 : UBATCH 2048+",
cfg: map[string]string{}, si: big, want: "UBATCH 2048+ recommandé"},
{name: "placement auto, UBATCH 4096 : rien",
cfg: map[string]string{"UBATCH": "4096", "BATCH": "4096"}, si: big},
{name: "-ub dans EXTRA_ARGS l'emporte sur UBATCH",
cfg: map[string]string{"UBATCH": "512", "EXTRA_ARGS": "-ub 2048"}, si: big},
{name: "le modèle tient en VRAM : rien",
cfg: map[string]string{},
si: serveSysInfo{Help: helpFit, GGUF: moe, ModelBytes: 20 * gib, VRAMMiB: 28 << 10, GPUs: 2}},
{name: "moteur sans --fit : garder --n-cpu-moe",
cfg: map[string]string{}, si: serveSysInfo{Help: helpOld, GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10},
want: "garde --n-cpu-moe"},
{name: "moteur sans --fit, NGL partiel : l'utilisateur a tranché",
cfg: map[string]string{"NGL": "20"}, si: serveSysInfo{Help: helpOld, GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10}},
{name: "aide illisible : rien",
cfg: map[string]string{}, si: serveSysInfo{GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10}},
{name: "-ot de la seule couche d'entrée : --fit coupé, experts tous sur GPU",
cfg: map[string]string{"EXTRA_ARGS": "-ot per_layer_token_embd.weight=CPU"}, si: big,
want: "-ot qui ne vise pas les experts"},
{name: "VRAM inconnue : rien",
cfg: map[string]string{}, si: serveSysInfo{Help: helpFit, GGUF: moe, ModelBytes: 82 * gib}},
{name: "macOS, mémoire unifiée : rien",
cfg: map[string]string{}, si: serveSysInfo{Help: helpFit, GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10, UnifiedMem: true}},
{name: "preset externe : rien",
cfg: map[string]string{extKeyFlag: "1", extKeyURL: "https://api.example.com/v1", "EXTRA_ARGS": "--n-cpu-moe 40"}, si: big},
}
for _, c := range cases {
notes := moeNotes(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
switch {
case c.want == "" && len(notes) != 0:
t.Errorf("%s : notes inattendues %q", c.name, notes)
case c.want != "" && (len(notes) != 1 || !strings.Contains(notes[0], c.want)):
t.Errorf("%s : notes %q, attendu %q", c.name, notes, c.want)
}
}
// Placement manuel en UBATCH 2048 : pas de conseil de lot.
n := moeNotes(map[string]string{"UBATCH": "2048"}, splitArgs("--n-cpu-moe 40"), big)
if len(n) != 1 || strings.Contains(n[0], "UBATCH") {
t.Errorf("conseil de lot en trop : %q", n)
}
}
// loadModeRisk : un avertissement d'ordinaire, un refus seulement quand la
// borne basse des poids en RAM dépasse 90 % de la RAM effective.
func TestLoadModeRisk(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
args string
si serveSysInfo
warn, refuse bool
}{
{name: "mmap : rien",
args: "--load-mode mmap", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}},
{name: "sans drapeau : rien",
si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}},
{name: "none, 66 Gio certains en RAM pour 60 : refus (#26110)",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true},
{name: "même chose, LOAD_GUARD=off : avertissement seulement",
cfg: map[string]string{"LOAD_GUARD": "off"}, args: "--load-mode none",
si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
{name: "mlock, 54 Gio en RAM pour 64 : avertissement",
args: "--load-mode mlock", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 28 << 10, RAMMiB: 64 << 10}, warn: true},
{name: "dense tout en VRAM sur une machine à peu de RAM : rien (pas de faux positif)",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 16 * gib, VRAMMiB: 24 << 10, RAMMiB: 8 << 10}},
{name: "VRAM inconnue : avertissement « jusqu'à », jamais de refus",
args: "--load-mode dio", si: serveSysInfo{ModelBytes: 82 * gib, RAMMiB: 60 << 10}, warn: true},
{name: "macOS, mémoire unifiée : tout le modèle compte",
args: "--load-mode mmap+mlock", si: serveSysInfo{ModelBytes: 60 * gib, RAMMiB: 64 << 10, UnifiedMem: true}, refuse: true},
{name: "moteur ancien : --no-mmap",
args: "--no-mmap", si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true},
{name: "moteur ancien : --mlock --no-mmap",
args: "--mlock --no-mmap", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 28 << 10, RAMMiB: 64 << 10}, warn: true},
{name: "LLAMA_ARG_NO_MMAP dans l'environnement",
si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10,
ArgEnv: map[string]string{"LLAMA_ARG_NO_MMAP": "1"}}, refuse: true},
// Corrections de relecture du lot 2 : pas de refus sur une borne fausse.
{name: "LLAMA_ARG_NO_MMAP sans effet sur un moteur à --load-mode : rien",
si: serveSysInfo{Help: "--load-mode", ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10,
ArgEnv: map[string]string{"LLAMA_ARG_NO_MMAP": "1"}}},
{name: "--rpc : une part des poids part ailleurs, avis seulement",
args: "--load-mode none --rpc 10.0.0.2:50052", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
{name: "LLAMA_ARG_RPC : de même",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10,
ArgEnv: map[string]string{"LLAMA_ARG_RPC": "10.0.0.2:50052"}}, warn: true},
{name: "limite du conteneur (RAM effective) : refus",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 40 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 16 << 10}, refuse: true},
// Lot 3 : la VRAM comptée est celle des cartes du moteur ; nvidia-smi
// seul ne fait jamais refuser.
{name: "VRAM NVIDIA seule, cartes du moteur non lues : avertissement, jamais de refus",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
{name: "VRAM NVIDIA seule, LOAD_GUARD=off : l'avis ordinaire",
cfg: map[string]string{"LOAD_GUARD": "off"}, args: "--load-mode none",
si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
{name: "Vulkan, NVIDIA 12 Gio + AMD 16 Gio listées par le moteur : rien (faux refus sur la seule VRAM NVIDIA)",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 70 * gib, VRAMMiB: 12 << 10, EngineVRAMMiB: 28 << 10, RAMMiB: 60 << 10}},
{name: "taille inconnue : rien",
args: "--load-mode none", si: serveSysInfo{VRAMMiB: 16 << 10, RAMMiB: 16 << 10}},
}
for _, c := range cases {
cfg := c.cfg
if cfg == nil {
cfg = map[string]string{}
}
warn, refuse := loadModeRisk(cfg, splitArgs(c.args), c.si)
if (warn != "") != c.warn || (refuse != "") != c.refuse {
t.Errorf("%s : warn=%q refuse=%q", c.name, warn, refuse)
}
if refuse != "" && !strings.Contains(refuse, "LOAD_GUARD=off") {
t.Errorf("%s : refus sans clé d'échappement : %q", c.name, refuse)
}
if loadGuardOff(cfg) && strings.Contains(warn, "refus") {
t.Errorf("%s : refus évoqué avec LOAD_GUARD=off : %q", c.name, warn)
}
}
}
// Sans ces clés, la ligne et l'environnement ne bougent pas quand le modèle
// est un MoE : seules des notes s'ajoutent.
func TestBuildServeArgsMoeNotesOnly(t *testing.T) {
const bin, model = "/opt/llama/llama-server", "/models/Qwen-MoE-Q4.gguf"
cfg := map[string]string{"CTX": "65536", "EXTRA_ARGS": "--n-cpu-moe 40 --load-mode none"}
extra := splitArgs(cfg["EXTRA_ARGS"])
plain := serveSysInfo{Help: helpFit, Model: model}
moe := plain
moe.GGUF, moe.ModelBytes, moe.VRAMMiB, moe.RAMMiB = &GGUFInfo{ExpertCount: 128}, 82*gib, 28<<10, 64<<10
a1, e1, n1 := buildServeArgs(cfg, extra, bin, plain)
a2, e2, n2 := buildServeArgs(cfg, extra, bin, moe)
if !reflect.DeepEqual(a1, a2) || !reflect.DeepEqual(e1, e2) {
t.Fatalf("la ligne a bougé :\n%q\n%q\nenv %v / %v", a1, a2, e1, e2)
}
if len(n2) != len(n1)+2 {
t.Fatalf("notes attendues (placement manuel + chargement) : %q", n2)
}
}
// La copie en placement auto du preset MoE de l'utilisateur.
func TestAutoPlacementPreset(t *testing.T) {
src := "# NAME=QWEN 3.6 MOE\n# experts à la main\nMODEL=Qwen-MoE-Q4.gguf\nCTX=65536\nNGL=999\nUBATCH=512\n" +
`EXTRA_ARGS="-ot per_layer_token_embd.weight=CPU --n-cpu-moe 40 --cache-type-k q8_0 --cache-type-v q8_0 --jinja --mlock --chat-template-file '/mes modèles/tpl.jinja'"` + "\n"
out, changes, err := autoPlacementPreset(src)
if err != nil {
t.Fatal(err)
}
cfg := parseEnv(out)
wantExtra := []string{"--cache-type-k", "q8_0", "--cache-type-v", "q8_0", "--jinja",
"--chat-template-file", "/mes modèles/tpl.jinja", "--load-mode", "mmap"}
if got := splitArgs(cfg["EXTRA_ARGS"]); !reflect.DeepEqual(got, wantExtra) {
t.Errorf("EXTRA_ARGS = %q\nwant %q", got, wantExtra)
}
if cfg["UBATCH"] != "2048" || cfg["BATCH"] != "4096" || cfg["CTX"] != "65536" || cfg["MODEL"] != "Qwen-MoE-Q4.gguf" {
t.Errorf("clés : %v", cfg)
}
if _, ok := cfg["NGL"]; ok {
t.Errorf("NGL gardé : %v", cfg)
}
if !strings.Contains(out, "# experts à la main") || !strings.HasPrefix(out, "# NAME=QWEN 3.6 MOE") {
t.Errorf("commentaires perdus :\n%s", out)
}
if len(changes) == 0 || !strings.Contains(strings.Join(changes, "\n"), "cache KV conservé") {
t.Errorf("changements : %q", changes)
}
// L'original n'est pas touché (chaîne passée par valeur, mais on vérifie la
// fonction : elle ne rend rien d'autre que la copie).
if parseEnv(src)["UBATCH"] != "512" {
t.Error("source modifiée")
}
refus := []struct{ name, content string }{
{"rien à migrer", "CTX=65536\nEXTRA_ARGS=--jinja\n"},
{"CTX=0", "CTX=0\nEXTRA_ARGS=--n-cpu-moe 40\n"},
{"-ot hors experts restant", `CTX=65536` + "\n" + `EXTRA_ARGS="-ot blk\.0\.attn.*=CUDA0,\.ffn_.*_exps=CPU"` + "\n"},
{"-sm row", "CTX=65536\nEXTRA_ARGS=\"--n-cpu-moe 40 -sm row\"\n"},
{"SPLIT_MODE=tensor", "CTX=65536\nSPLIT_MODE=tensor\nEXTRA_ARGS=--n-cpu-moe 40\n"},
{"--n-cpu-ffn", "CTX=65536\nEXTRA_ARGS=\"--n-cpu-moe 40 --n-cpu-ffn 4\"\n"},
{"preset externe", extKeyFlag + "=1\n" + extKeyURL + "=https://api.example.com/v1\nEXTRA_ARGS=--n-cpu-moe 40\n"},
}
for _, r := range refus {
if _, _, err := autoPlacementPreset(r.content); err == nil {
t.Errorf("%s : accepté", r.name)
}
}
// --cpu-moe et -ngl / --tensor-split / --fit off retirés ensemble.
out, _, err = autoPlacementPreset("CTX=32768\nEXTRA_ARGS=\"-cmoe -ngl 99 -ts 1,1 --fit off -ub 512\"\n")
if err != nil {
t.Fatal(err)
}
if got := parseEnv(out)["EXTRA_ARGS"]; got != "--load-mode mmap" {
t.Errorf("EXTRA_ARGS = %q", got)
}
}
// presetSetKey : remplace, ajoute, retire — sans toucher aux voisines (BATCH
// n'est pas UBATCH) ni aux commentaires.
func TestPresetSetKey(t *testing.T) {
src := "# BATCH=1\nUBATCH=512\nBATCH=2048\n"
if got := presetSetKey(src, "BATCH", "4096"); got != "# BATCH=1\nUBATCH=512\nBATCH=4096\n" {
t.Errorf("remplacement : %q", got)
}
if got := presetSetKey(src, "NGL", "auto"); got != src+"NGL=auto\n" {
t.Errorf("ajout : %q", got)
}
if got := presetSetKey("A=1", "B", "2"); got != "A=1\nB=2\n" {
t.Errorf("ajout sans fin de ligne : %q", got)
}
if got := presetSetKey(src, "UBATCH", ""); got != "# BATCH=1\nBATCH=2048\n" {
t.Errorf("retrait : %q", got)
}
if got := presetSetKey("X=\"a b\"\n", "X", "c d"); parseEnv(got)["X"] != "c d" {
t.Errorf("guillemets : %q", got)
}
// Clé en double : parseEnv retient la dernière, toutes sont réécrites.
if got := presetSetKey("NGL=999\nCTX=1\nexport NGL=40\n", "NGL", "auto"); parseEnv(got)["NGL"] != "auto" {
t.Errorf("doublon : %q", got)
}
if got := presetSetKey("NGL=999\nCTX=1\nNGL=40", "NGL", ""); got != "CTX=1\n" {
t.Errorf("retrait des doublons : %q", got)
}
}
// joinArgs relu par splitArgs rend les mêmes arguments.
func TestJoinArgsRoundTrip(t *testing.T) {
args := []string{"--jinja", "--chat-template-file", "/mes modèles/tpl.jinja", "-ot", `blk\.(1|2)\.ffn=CPU`, "", "it's here"}
if got := splitArgs(joinArgs(args)); !reflect.DeepEqual(got, args) {
t.Errorf("aller-retour : %q", got)
}
}
// Le refus de LOAD_GUARD s'arrête avant tout chargement : l'interface doit le
// dire plutôt qu'un « chargement… » sans fin, même après un chargement réussi
// plus haut dans le journal.
func TestModelLoadErrorLoadGuard(t *testing.T) {
_, refuse := loadModeRisk(map[string]string{}, splitArgs("--load-mode none"),
serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10})
log := "llama_model_load: loading model\nmain: model loaded\nserver is listening\n[ERREUR] " + refuse + "\n"
if got := modelLoadErrorFrom(log); !strings.Contains(got, "LOAD_GUARD=off") {
t.Errorf("refus non signalé : %q", got)
}
// Une tentative suivante qui charge efface le refus.
if got := modelLoadErrorFrom(log + "loading model\nmodel loaded\n"); got != "" {
t.Errorf("refus ancien encore signalé : %q", got)
}
}
// Un refus de LOAD_GUARD ne doit pas être relancé en boucle : l'unité systemd
// écrite par « loki install » exclut son code de sortie, une unité d'avant ne
// le fait pas.
func TestUnitPreventsRefusalRestart(t *testing.T) {
for _, c := range []struct {
unit string
want bool
}{
{"[Service]\nRestart=on-failure\nRestartSec=3\n", false},
{"[Service]\nRestart=on-failure\nRestartPreventExitStatus=78\n", true},
{"[Service]\n RestartPreventExitStatus = 1 78 SIGKILL\n", true},
{"[Service]\nRestartPreventExitStatus=178\n", false},
{"[Service]\n# RestartPreventExitStatus=78\n", false},
} {
if got := unitPreventsRefusalRestart(c.unit); got != c.want {
t.Errorf("%q : %v, attendu %v", c.unit, got, c.want)
}
}
}
// Le refus sort avec son propre code (pas 1), y compris enveloppé ; une erreur
// ordinaire garde 1. Le moteur d'essai garde toujours le vrai code.
func TestLoadGuardRefusalExitStatus(t *testing.T) {
err := loadGuardRefusal("refus ; "+loadGuardMarker, true)
if got := exitStatusOf(err); got != loadGuardExitStatus {
t.Errorf("essai : code %d, attendu %d", got, loadGuardExitStatus)
}
if !strings.Contains(err.Error(), loadGuardMarker) {
t.Errorf("raison perdue : %q", err)
}
if got := exitStatusOf(fmt.Errorf("enveloppé : %w", err)); got != loadGuardExitStatus {
t.Errorf("enveloppé : code %d", got)
}
if got := exitStatusOf(errors.New("modèle introuvable")); got != 1 {
t.Errorf("erreur ordinaire : code %d, attendu 1", got)
}
// Hors superviseur (les tests ne tournent ni sous systemd ni sous launchd) :
// le vrai code aussi.
if restarts, _ := supervisorRestartsRefusal(); !restarts {
if got := exitStatusOf(loadGuardRefusal("refus", false)); got != loadGuardExitStatus {
t.Errorf("hors superviseur : code %d", got)
}
}
}
// Le refus envisagé sur la VRAM NVIDIA attend les cartes du moteur ; rien
// n'est lu sans refus en vue.
func TestLoadGuardNeedsDevices(t *testing.T) {
none := splitArgs("--load-mode none")
base := serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}
if !loadGuardNeedsDevices(map[string]string{}, none, base) {
t.Error("refus en vue sur nvidia-smi : cartes du moteur non demandées")
}
confirmed := base
confirmed.EngineVRAMMiB = 16 << 10
for name, c := range map[string]struct {
cfg map[string]string
args []string
si serveSysInfo
}{
"déjà lues": {map[string]string{}, none, confirmed},
"LOAD_GUARD=off": {map[string]string{"LOAD_GUARD": "off"}, none, base},
"mmap": {map[string]string{}, splitArgs("--load-mode mmap"), base},
"VRAM inconnue": {map[string]string{}, none, serveSysInfo{ModelBytes: 82 * gib, RAMMiB: 60 << 10}},
"loin du seuil": {map[string]string{}, none, serveSysInfo{ModelBytes: 30 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}},
} {
if loadGuardNeedsDevices(c.cfg, c.args, c.si) {
t.Errorf("%s : cartes demandées sans refus en vue", name)
}
}
}
// La VRAM des cartes du moteur : toutes additionnées, inconnue dès qu'une
// carte annonce 0 Mio (déjà pleine) ; devices.json relu en JSON (float64).
func TestEngineDevsVRAM(t *testing.T) {
mixed := splitDevsFrom([]map[string]any{
{"id": "Vulkan0", "total_mib": 12288},
{"id": "Vulkan1", "total_mib": float64(16368)},
})
if got := engineDevsVRAM(mixed); got != 12288+16368 {
t.Errorf("cartes mixtes : %d", got)
}
if got := engineDevsVRAM([]splitDev{{"CUDA0", 12288}, {"CUDA1", 0}}); got != 0 {
t.Errorf("carte à 0 Mio : %d, attendu inconnu", got)
}
if got := engineDevsVRAM(nil); got != 0 {
t.Errorf("liste vide : %d", got)
}
}
+267
View File
@@ -0,0 +1,267 @@
package loki
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// État du slot gardé à travers une bascule de preset (SLOT_PERSIST, off par
// défaut).
//
// Basculer d'un preset A vers B puis revenir à A relance llama-server deux
// fois : au retour, la conversation de A est recalculée en entier — 30 à 80 s
// sur un 27B dense. Avec SLOT_PERSIST=on, Loki demande au moteur, juste avant
// la bascule, d'écrire l'état du slot 0 sur disque (/slots/0?action=save), et
// le recharge au retour, avant la première requête de la discussion
// (llm_slotpersist.go). llama-server ne reprend un état que sur un préfixe de
// jetons identique : au pire, rien n'est repris.
//
// Mais rien, dans le fichier, ne dit quel moteur ni quels réglages ont calculé
// ce cache : llama.cpp ne vérifie que les types et les tailles. Le recharger
// sous un autre build, une autre fenêtre ou un autre placement donnerait au
// modèle un contexte que ce moteur n'aurait jamais produit. D'où la clé : une
// empreinte de TOUT ce qui touche au calcul — ligne de commande complète
// (modèle, fenêtre, types KV, NGL, lots, gabarit, EXTRA_ARGS…), variables
// LLAMA_ARG_*, GGML_* et CUDA_*, identité (taille, date) du modèle et de ses
// tranches, du projecteur, du brouillon, du binaire et de ses bibliothèques.
// Un fichier ne se recharge que sous la même clé ; une mise à jour du moteur
// change donc tout, et n'est jamais un cas de reprise.
//
// Refusé d'emblée : le décodage spéculatif (la sauvegarde de llama.cpp ne
// garde que le modèle cible, pas le brouillon MTP — l'état rechargé serait
// incohérent pour lui), les poids sur CPU (MoE déporté : états de plusieurs
// Gio pour un recalcul de toute façon lent), et un moteur joignable par
// d'autres sans clé d'API : save et restore écrivent des Gio sur le disque.
// slotPersistOn : la clé SLOT_PERSIST est-elle posée ?
func slotPersistOn(cfg map[string]string) bool {
switch strings.ToLower(strings.TrimSpace(cfg["SLOT_PERSIST"])) {
case "on", "1", "true", "yes", "oui":
return true
}
return false
}
// slotPersistArgEnv : variables qui contredisent SLOT_PERSIST sans drapeau.
var slotPersistArgEnv = []string{"LLAMA_ARG_SLOT_SAVE_PATH", "LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL",
"LLAMA_ARG_SPEC_DRAFT_HF_REPO"}
// probeSlotPersistEnv complète ArgEnv, seulement si la clé est posée.
func probeSlotPersistEnv(cfg map[string]string, si *serveSysInfo) {
if !slotPersistOn(cfg) {
return
}
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range slotPersistArgEnv {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
}
// slotPersistPlan : SLOT_PERSIST vaut-il pour ce lancement ? why dit pourquoi
// non (vide = clé absente).
func slotPersistPlan(cfg map[string]string, extra []string, si serveSysInfo) (ok bool, why string) {
if !slotPersistOn(cfg) {
return false, ""
}
switch {
case isExternalConfig(cfg):
return false, "preset externe"
case !strings.Contains(si.Help, "--slot-save-path"):
return false, "ce moteur ne connaît pas --slot-save-path"
case hasAnyFlag(extra, "--slot-save-path") || si.ArgEnv["LLAMA_ARG_SLOT_SAVE_PATH"] != "":
return false, "--slot-save-path est déjà réglé à la main"
case si.SlotDir == "":
return false, "dossier des slots indisponible"
case specMode(cfg) != "off" || specUserSet(extra, si.ArgEnv) || si.ArgEnv["LLAMA_ARG_SPEC_TYPE"] != "":
return false, "décodage spéculatif : la sauvegarde du moteur ne garde pas l'état du brouillon"
case splitModeKey(cfg) != "":
return false, "SPLIT_MODE : état réparti entre cartes, sauvegarde non validée"
}
if !slotSaveSafe(cfg, extra, si) {
return false, "moteur joignable par d'autres sans clé d'API (HOST=127.0.0.1 ou une clé d'API)"
}
blocks := 0
if si.GGUF != nil {
blocks = si.GGUF.BlockCount
}
if r := cpuWeights(cfg, extra, si.ArgEnv, blocks); r != "" {
return false, r
}
return true, ""
}
// slotSaveSafe : le moteur n'est joignable que par la machine, ou exige une
// clé d'API. Sans ça, --slot-save-path donnerait à n'importe qui le droit
// d'écrire des Gio sur le disque.
func slotSaveSafe(cfg map[string]string, extra []string, si serveSysInfo) bool {
host := flagValue(extra, "--host")
if host == "" {
host = cfg["HOST"]
}
if host == "" {
host = "0.0.0.0"
}
return loopbackHost(host) || si.APIKey != ""
}
// slotPersistNote : ce que le lancement dit de SLOT_PERSIST.
func slotPersistNote(ok bool, why string) string {
if ok || why == "" {
return ""
}
return "SLOT_PERSIST refusé (" + why + ") : l'état du slot ne sera pas gardé à la bascule de preset"
}
// slotPersistFileRe : les seuls fichiers que Loki écrit dans le dossier des
// slots — clé du moteur, empreinte de la discussion.
var slotPersistFileRe = regexp.MustCompile(`^persist-[0-9a-f]{16}-[0-9a-f]{12}\.bin$`)
// slotPersistMaxFiles : au plus deux états gardés (A→B→A, puis B→A→B).
const slotPersistMaxFiles = 2
// slotPersistKey : l'empreinte de tout ce qui décide du calcul du cache.
// args : la ligne de commande finale (la clé d'API n'y entre pas) ; env : ce
// que buildServeArgs pose. Les fichiers sont pris par taille et date : un
// modèle retéléchargé, un moteur mis à jour changent la clé.
func slotPersistKey(args []string, env map[string]string, si serveSysInfo, bin string) string {
h := sha256.New()
w := func(s string) { h.Write([]byte(s)); h.Write([]byte{0}) }
for i := 0; i < len(args); i++ {
if args[i] == "--api-key" {
i++
continue
}
w(args[i])
}
vars := map[string]string{}
for _, kv := range os.Environ() {
k, v, _ := strings.Cut(kv, "=")
if strings.HasPrefix(k, "LLAMA_ARG_") || strings.HasPrefix(k, "GGML_") || strings.HasPrefix(k, "CUDA_") {
vars[k] = v
}
}
for k, v := range env {
vars[k] = v
}
delete(vars, "LLAMA_ARG_API_KEY")
keys := make([]string, 0, len(vars))
for k := range vars {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
w(k + "=" + vars[k])
}
stat := func(p string) {
if p == "" {
return
}
if st, err := os.Stat(p); err == nil {
w(fmt.Sprintf("%s|%d|%d", p, st.Size(), st.ModTime().UnixNano()))
} else {
w(p + "|?")
}
}
if si.Model != "" {
for _, n := range shardFamily(filepath.Base(si.Model)) {
stat(filepath.Join(filepath.Dir(si.Model), n))
}
stat(si.Model)
}
stat(si.MMProj)
stat(si.Draft)
stat(bin)
// Bibliothèques du moteur (libllama, ggml-cuda…) : une mise à jour peut
// les changer sans toucher au binaire.
if entries, err := os.ReadDir(filepath.Dir(bin)); err == nil {
for _, e := range entries {
n := strings.ToLower(e.Name())
if strings.HasSuffix(n, ".so") || strings.Contains(n, ".so.") || strings.HasSuffix(n, ".dll") || strings.HasSuffix(n, ".dylib") {
stat(filepath.Join(filepath.Dir(bin), e.Name()))
}
}
}
w(fmt.Sprintf("build=%d", si.EngineBuild))
return hex.EncodeToString(h.Sum(nil))[:16]
}
// slotPersistMarker : la clé du moteur lancé, écrite par « loki serve » juste
// avant de lui céder la place, lue par le process web. Hors du dossier des
// slots : le moteur ne peut pas l'écraser par un save.
func slotPersistMarker(home string) string { return filepath.Join(home, "slots-engine.key") }
// writeSlotPersistMarker pose (key non vide) ou retire la clé du moteur.
func writeSlotPersistMarker(home, key string) {
p := slotPersistMarker(home)
if key == "" {
_ = os.Remove(p)
return
}
_ = os.WriteFile(p, []byte(key+"\n"), 0o600)
}
// readSlotPersistMarker : la clé du moteur en service ; vide = SLOT_PERSIST
// refusé ou absent à son lancement.
func readSlotPersistMarker(home string) string {
b, err := os.ReadFile(slotPersistMarker(home))
if err != nil {
return ""
}
k := strings.TrimSpace(string(b))
if len(k) != 16 {
return ""
}
if _, err := hex.DecodeString(k); err != nil {
return ""
}
return k
}
// slotPersistPrune garde les slotPersistMaxFiles états les plus récents.
func slotPersistPrune(dir string) {
type f struct {
name string
at int64
}
var fs []f
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if !e.Type().IsRegular() || !slotPersistFileRe.MatchString(e.Name()) {
continue
}
if info, err := e.Info(); err == nil {
fs = append(fs, f{e.Name(), info.ModTime().UnixNano()})
}
}
sort.Slice(fs, func(i, j int) bool { return fs[i].at > fs[j].at })
for i := slotPersistMaxFiles; i < len(fs); i++ {
_ = os.Remove(filepath.Join(dir, fs[i].name))
}
}
// slotPersistPurge retire tous les états gardés : la clé n'est plus là, ils
// n'ont plus rien à faire sur le disque.
func slotPersistPurge(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if e.Type().IsRegular() && slotPersistFileRe.MatchString(e.Name()) {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
}
+279
View File
@@ -0,0 +1,279 @@
package loki
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// Files de lancement CUDA (CUDA_SCALE_LAUNCH_QUEUES) sur deux GPU ou plus.
//
// Quand llama.cpp répartit les couches entre plusieurs cartes et que le modèle
// y tient en entier, il fait travailler les cartes en pipeline : pendant que la
// seconde calcule un micro-lot, la première attaque le suivant. Encore faut-il
// que le CPU puisse empiler assez de lancements CUDA d'avance ; avec la file par
// défaut du pilote, il bloque et le pipeline se vide. « 4x » quadruple cette
// file (PR llama.cpp #19042) : c'est une affaire de pilote, pas de calcul — mêmes
// noyaux, mêmes entrées, même ordre, donc une sortie identique au jeton près. Le
// prix : ~120 Mo de RAM système par GPU.
//
// llama.cpp l'a posé par défaut puis retiré (#19227) après des blocages sur
// Jetson (mémoire unifiée, MoE décodé en partie sur CPU) ; ses docs le
// recommandent désormais à la main en multi-GPU. Loki le pose donc lui-même,
// mais seulement là où le pipeline peut réellement exister (voir
// pipelineBlocker) : ailleurs le gain est nul et l'on ne s'exposerait qu'au
// seul cas de panne jamais rapporté.
//
// Gain non mesuré sur la machine de référence (5060 Ti + 3060, 27B) : en amont,
// +10 à 25 % de prompt seulement sur un 70B réparti sur deux grosses cartes,
// rien de mesurable sur un 8B. Le décodage ne bouge pas. Pour vérifier qu'il
// s'applique, le journal du moteur doit dire « pipeline parallelism enabled ».
//
// Choix de l'utilisateur, par ordre de priorité :
//
// CUDA_SCALE_LAUNCH_QUEUES déjà dans l'environnement → intouché (conteneur Unraid…)
// CUDA_LAUNCH_QUEUES=off → rien
// CUDA_LAUNCH_QUEUES=0.25x|0.5x|2x|4x → cette valeur, sans condition
// vide ou auto → 4x si ≥ 2 GPU et pipeline possible
//
// Une autre valeur serait lue comme 1x par le pilote, sans un mot : on l'ignore
// en le disant.
const launchQueuesAuto = "4x"
// launchQueueValues : les seules valeurs que le pilote CUDA comprend.
var launchQueueValues = map[string]bool{"0.25x": true, "0.5x": true, "2x": true, "4x": true}
// serveArgEnv : les variables LLAMA_ARG_* qui font, sans drapeau, ce que lit
// pipelineBlocker. llama.cpp les applique quand le drapeau manque — les ignorer
// ferait conclure à un pipeline que le moteur n'ouvrira pas.
var serveArgEnv = []string{"LLAMA_ARG_DEVICE", "LLAMA_ARG_SPLIT_MODE", "LLAMA_ARG_N_GPU_LAYERS",
"LLAMA_ARG_OVERRIDE_TENSOR", "LLAMA_ARG_CPU_MOE", "LLAMA_ARG_N_CPU_MOE", "LLAMA_ARG_N_CPU_FFN",
"LLAMA_ARG_KV_OFFLOAD", "LLAMA_ARG_NO_KV_OFFLOAD"}
// launchQueuesEnv décide de CUDA_SCALE_LAUNCH_QUEUES. Fonction pure : le nombre
// de GPU et l'environnement arrivent déjà sondés dans si. Valeur vide = ne rien
// poser.
func launchQueuesEnv(cfg map[string]string, extra []string, si serveSysInfo) (val string, notes []string) {
if si.LaunchQueues != "" {
return "", nil
}
want := strings.ToLower(strings.TrimSpace(cfg["CUDA_LAUNCH_QUEUES"]))
switch {
case want == "off":
return "", nil
case launchQueueValues[want]:
return want, []string{"CUDA_SCALE_LAUNCH_QUEUES=" + want + " (CUDA_LAUNCH_QUEUES)"}
case want != "" && want != "auto":
notes = append(notes, "CUDA_LAUNCH_QUEUES="+cfg["CUDA_LAUNCH_QUEUES"]+
" ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique")
}
n := servedGPUCount(extra, si)
if n < 2 || pipelineBlocker(cfg, extra, si.ArgEnv) != "" {
return "", notes
}
return launchQueuesAuto, append(notes, fmt.Sprintf("CUDA_SCALE_LAUNCH_QUEUES=%s (%d GPU, parallélisme pipeline "+
"possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", launchQueuesAuto, n))
}
// servedGPUCount : combien de GPU CUDA llama-server utilisera. Le --device du
// preset d'abord (c'est là que l'éditeur range le choix des cartes), puis ce
// que cmdServe a compté (CUDA_VISIBLE_DEVICES, sinon nvidia-smi). Un --device
// qui ne nomme que des cartes Vulkan ou Metal compte zéro : la variable ne
// concerne que CUDA.
func servedGPUCount(extra []string, si serveSysInfo) int {
dev := flagValue(extra, "-dev", "--device")
if dev == "" {
dev = si.ArgEnv["LLAMA_ARG_DEVICE"]
}
if dev == "" {
return si.GPUs
}
n := 0
for _, d := range strings.Split(dev, ",") {
if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(d)), "CUDA") {
n++
}
}
return n
}
// pipelineBlocker dit pourquoi llama.cpp n'ouvrira PAS le pipeline entre
// cartes (llama-context.cpp : toutes les couches sur GPU, découpe par couches,
// cache KV sur GPU, aucune surcharge de tenseurs). Vide = il peut l'ouvrir.
// --n-cpu-moe, --cpu-moe et --n-cpu-ffn sont des surcharges de tenseurs
// déguisées : le preset MoE à experts sur CPU n'a donc jamais de pipeline.
//
// Attention à la façon dont llama.cpp lit ces réglages (common/arg.cpp) : les
// variables LLAMA_ARG_* passent d'abord, la ligne de commande ensuite. Pour un
// réglage à valeur unique (-sm, -ngl, -kvo/-nkvo), la ligne de commande
// l'emporte ; mais les surcharges de tenseurs S'ACCUMULENT — variable et
// drapeaux, chaque occurrence ajoute les siennes. Un --n-cpu-moe 0 n'annule
// donc pas un LLAMA_ARG_N_CPU_MOE=30.
func pipelineBlocker(cfg map[string]string, extra []string, argEnv map[string]string) string {
if r := tensorOverride(extra, argEnv); r != "" {
return r
}
if !kvOffloaded(extra, argEnv) {
return "cache KV sur CPU"
}
sm := flagValue(extra, "-sm", "--split-mode")
if sm == "" {
sm = argEnv["LLAMA_ARG_SPLIT_MODE"]
}
if sm != "" && sm != "layer" {
return "découpe " + sm
}
// Couches GPU : celles d'EXTRA_ARGS, sinon NGL (que buildServeArgs traduit
// toujours en -ngl, sauf NGL=auto qui ne pose rien — la variable du moteur
// décide alors). Un nombre choisi — hors la sentinelle 999 — laisse en
// général des couches au CPU ; auto, all et 999 visent le tout-GPU.
ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
if ngl == "" {
ngl = strings.TrimSpace(cfg["NGL"])
if strings.EqualFold(ngl, "auto") {
ngl = argEnv["LLAMA_ARG_N_GPU_LAYERS"]
}
}
if ngl = strings.ToLower(strings.TrimSpace(ngl)); ngl != "" && ngl != "auto" && ngl != "all" && ngl != "999" {
return "couches GPU limitées à " + ngl
}
return ""
}
// tensorOverride dit si des tenseurs sont placés à la main (-ot et ses formes
// déguisées --cpu-moe, --n-cpu-moe, --n-cpu-ffn), en drapeau ou en variable.
// Vide = aucun. Le pipeline entre cartes s'y arrête, et --fit aussi
// (« tensor_buft_overrides already set by user, abort »).
func tensorOverride(extra []string, argEnv map[string]string) string {
if hasAnyFlag(extra, "-ot", "--override-tensor", "--cpu-moe", "-cmoe") ||
argEnv["LLAMA_ARG_OVERRIDE_TENSOR"] != "" || envTruthy(argEnv["LLAMA_ARG_CPU_MOE"]) {
return "surcharge de tenseurs"
}
for _, n := range []struct{ env, short, long string }{
{"LLAMA_ARG_N_CPU_MOE", "-ncmoe", "--n-cpu-moe"},
{"LLAMA_ARG_N_CPU_FFN", "-ncffn", "--n-cpu-ffn"},
} {
for _, v := range append(flagValues(extra, n.short, n.long), argEnv[n.env]) {
if v = strings.TrimSpace(v); v != "" && v != "0" {
return "couches " + n.long + " sur CPU"
}
}
}
return ""
}
// kvOffloaded : le cache KV reste-t-il sur GPU ? Le dernier -kvo/-nkvo de la
// ligne de commande tranche ; sinon LLAMA_ARG_NO_KV_OFFLOAD, dont la SEULE
// présence vaut « non » pour llama.cpp (quelle que soit sa valeur), puis
// LLAMA_ARG_KV_OFFLOAD lu comme un booléen.
func kvOffloaded(extra []string, argEnv map[string]string) bool {
on, set := true, false
for _, a := range extra {
name, _, _ := strings.Cut(a, "=")
switch name {
case "-kvo", "--kv-offload":
on, set = true, true
case "-nkvo", "--no-kv-offload":
on, set = false, true
}
}
if set {
return on
}
if argEnv["LLAMA_ARG_NO_KV_OFFLOAD"] != "" {
return false
}
switch strings.ToLower(strings.TrimSpace(argEnv["LLAMA_ARG_KV_OFFLOAD"])) {
case "off", "disabled", "false", "0":
return false
}
return true
}
// envTruthy lit un drapeau sans valeur passé par variable, comme llama.cpp :
// seuls on, enabled, true et 1 l'activent.
func envTruthy(v string) bool {
switch strings.TrimSpace(v) {
case "on", "enabled", "true", "1":
return true
}
return false
}
// flagValues : les valeurs de TOUTES les occurrences d'un drapeau (« -x 1 » ou
// « -x=1 »), pour ceux que llama.cpp cumule au lieu de garder le dernier.
func flagValues(args []string, flags ...string) []string {
var vals []string
for i, a := range args {
name, val, hasEq := strings.Cut(a, "=")
for _, f := range flags {
if name != f {
continue
}
if hasEq {
vals = append(vals, val)
} else if i+1 < len(args) {
vals = append(vals, args[i+1])
}
}
}
return vals
}
// probeServeGPUs remplit ce dont launchQueuesEnv a besoin. On ne sonde
// nvidia-smi que si la réponse peut servir : variable déjà posée, « off », un
// --device explicite ou CUDA_VISIBLE_DEVICES suffisent à trancher sans lui.
func probeServeGPUs(cfg map[string]string, extra []string, si *serveSysInfo) {
si.LaunchQueues = os.Getenv("CUDA_SCALE_LAUNCH_QUEUES")
si.ArgEnv = map[string]string{}
for _, k := range serveArgEnv {
if v, ok := os.LookupEnv(k); ok && v != "" {
si.ArgEnv[k] = v
} else if ok && k == "LLAMA_ARG_NO_KV_OFFLOAD" {
si.ArgEnv[k] = "1" // présente mais vide : llama.cpp n'y lit que la présence
}
}
if si.LaunchQueues != "" || strings.EqualFold(strings.TrimSpace(cfg["CUDA_LAUNCH_QUEUES"]), "off") {
return
}
if hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" {
return
}
// cmdServe a déjà posé la sélection de config.env dans l'environnement :
// os.Getenv voit donc le preset d'abord, le conteneur ensuite.
if cvd := strings.TrimSpace(os.Getenv("CUDA_VISIBLE_DEVICES")); cvd != "" {
for _, d := range strings.Split(cvd, ",") {
if strings.TrimSpace(d) != "" {
si.GPUs++
}
}
return
}
si.GPUs = nvidiaGPUCount(3 * time.Second)
}
// nvidiaGPUCount compte les cartes NVIDIA. Borné dans le temps : un pilote
// coincé peut faire pendre nvidia-smi, et le démarrage du moteur ne doit pas
// en dépendre. Au moindre doute, 0 — on ne pose rien.
func nvidiaGPUCount(timeout time.Duration) int {
if !hasTool("nvidia-smi") {
return 0
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
out, err := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index", "--format=csv,noheader")).Output()
if err != nil {
return 0
}
n := 0
for _, line := range strings.Split(string(out), "\n") {
if strings.TrimSpace(line) != "" {
n++
}
}
return n
}
+136
View File
@@ -0,0 +1,136 @@
package loki
import (
"reflect"
"testing"
)
// TestLaunchQueuesEnv : CUDA_SCALE_LAUNCH_QUEUES n'est posé que là où le
// pipeline entre cartes peut exister, et jamais par-dessus un choix de
// l'utilisateur.
func TestLaunchQueuesEnv(t *testing.T) {
two := serveSysInfo{GPUs: 2}
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want string
wantNotes int
}{
{name: "2 GPU, dense tout sur GPU : 4x, et on le dit",
cfg: map[string]string{"NGL": "999"}, si: two, want: "4x", wantNotes: 1},
{name: "NGL=all ou auto : pipeline possible",
cfg: map[string]string{"NGL": "all"}, si: two, want: "4x", wantNotes: 1},
{name: "1 GPU : rien",
cfg: map[string]string{"NGL": "999"}, si: serveSysInfo{GPUs: 1}},
{name: "nombre de GPU inconnu : rien",
cfg: map[string]string{}},
{name: "variable déjà posée par l'utilisateur : intouchée",
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "2x"}, si: serveSysInfo{GPUs: 2, LaunchQueues: "1x"}},
{name: "CUDA_LAUNCH_QUEUES=off : rien",
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "off"}, si: two},
{name: "valeur explicite valide : posée telle quelle, même sur 1 GPU",
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "2X"}, si: serveSysInfo{GPUs: 1}, want: "2x", wantNotes: 1},
{name: "valeur invalide : ignorée, dite, puis automatique",
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "8x"}, si: two, want: "4x", wantNotes: 2},
{name: "valeur invalide sur 1 GPU : seulement la note",
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "beaucoup"}, si: serveSysInfo{GPUs: 1}, wantNotes: 1},
{name: "preset MoE (-ot, --n-cpu-moe) sur 2 GPU : rien",
cfg: map[string]string{"EXTRA_ARGS": `-ot "per_layer_token_embd=CPU" --n-cpu-moe 40`}, si: two},
{name: "--n-cpu-moe seul : rien",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe=12"}, si: two},
{name: "--n-cpu-moe 0 : aucun expert sur CPU, pipeline possible",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 0"}, si: two, want: "4x", wantNotes: 1},
{name: "--cpu-moe : rien",
cfg: map[string]string{"EXTRA_ARGS": "--cpu-moe"}, si: two},
{name: "-sm row : rien",
cfg: map[string]string{"EXTRA_ARGS": "-sm row"}, si: two},
{name: "--split-mode=layer explicite : 4x",
cfg: map[string]string{"EXTRA_ARGS": "--split-mode=layer"}, si: two, want: "4x", wantNotes: 1},
{name: "NGL=28 : des couches restent au CPU, rien",
cfg: map[string]string{"NGL": "28"}, si: two},
{name: "-ngl 40 dans EXTRA_ARGS l'emporte sur NGL=999 : rien",
cfg: map[string]string{"NGL": "999", "EXTRA_ARGS": "-ngl 40"}, si: two},
{name: "cache KV sur CPU : rien",
cfg: map[string]string{"EXTRA_ARGS": "-nkvo"}, si: two},
{name: "--device d'une seule carte : rien",
cfg: map[string]string{"EXTRA_ARGS": "--device CUDA0"}, si: two},
{name: "--device de deux cartes CUDA, nvidia-smi non sondé : 4x",
cfg: map[string]string{"EXTRA_ARGS": "--device CUDA1,CUDA0 --tensor-split 0.6,0.4"}, want: "4x", wantNotes: 1},
{name: "--device Vulkan : la variable ne concerne pas ce moteur",
cfg: map[string]string{"EXTRA_ARGS": "-dev Vulkan0,Vulkan1"}, si: two},
{name: "--device none : rien",
cfg: map[string]string{"EXTRA_ARGS": "-dev none"}, si: two},
{name: "LLAMA_ARG_N_CPU_MOE dans l'environnement du moteur : rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_CPU_MOE": "30"}}},
{name: "LLAMA_ARG_SPLIT_MODE=none : rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_SPLIT_MODE": "none"}}},
{name: "NGL=auto et LLAMA_ARG_N_GPU_LAYERS=20 : rien",
cfg: map[string]string{"NGL": "auto"}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_GPU_LAYERS": "20"}}},
{name: "LLAMA_ARG_CPU_MOE=0 : désactivé, pipeline possible",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_CPU_MOE": "0"}}, want: "4x", wantNotes: 1},
{name: "LLAMA_ARG_DEVICE d'une seule carte : rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_DEVICE": "CUDA1"}}},
// Relecture : ce que llama.cpp fait vraiment de ses variables.
{name: "--n-cpu-moe 0 n'annule pas LLAMA_ARG_N_CPU_MOE (surcharges cumulées) : rien",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 0"},
si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_CPU_MOE": "30"}}},
{name: "deux --n-cpu-moe, le dernier à 0 : le premier compte quand même, rien",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 20 --n-cpu-moe 0"}, si: two},
{name: "--n-cpu-ffn (FFN dense sur CPU) : rien",
cfg: map[string]string{"EXTRA_ARGS": "-ncffn 8"}, si: two},
{name: "LLAMA_ARG_OVERRIDE_TENSOR : rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_OVERRIDE_TENSOR": "exps=CPU"}}},
{name: "LLAMA_ARG_CPU_MOE=yes : llama.cpp ne l'active pas, 4x",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_CPU_MOE": "yes"}}, want: "4x", wantNotes: 1},
{name: "LLAMA_ARG_NO_KV_OFFLOAD=0 : sa seule présence coupe l'offload, rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_NO_KV_OFFLOAD": "0"}}},
{name: "LLAMA_ARG_KV_OFFLOAD=false : rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_KV_OFFLOAD": "false"}}},
{name: "-kvo sur la ligne l'emporte sur LLAMA_ARG_NO_KV_OFFLOAD : 4x",
cfg: map[string]string{"EXTRA_ARGS": "-kvo"},
si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_NO_KV_OFFLOAD": "1"}}, want: "4x", wantNotes: 1},
{name: "-nkvo puis -kvo : le dernier gagne, 4x",
cfg: map[string]string{"EXTRA_ARGS": "-nkvo -kvo"}, si: two, want: "4x", wantNotes: 1},
{name: "NGL absent : Loki passe -ngl auto, LLAMA_ARG_N_GPU_LAYERS ne compte pas, 4x",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_GPU_LAYERS": "20"}}, want: "4x", wantNotes: 1},
{name: "NGL=Auto (casse libre, comme nglArgs) : 4x",
cfg: map[string]string{"NGL": "Auto"}, si: two, want: "4x", wantNotes: 1},
{name: "-sm tensor : pas de pipeline par couches, rien",
cfg: map[string]string{"EXTRA_ARGS": "-sm tensor"}, si: two},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, notes := launchQueuesEnv(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
if got != c.want {
t.Fatalf("valeur = %q, attendu %q", got, c.want)
}
if len(notes) != c.wantNotes {
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
}
})
}
}
// La variable passe par l'environnement : la ligne de commande ne bouge pas
// d'un octet, et la sélection GPU l'accompagne.
func TestBuildServeArgsLaunchQueues(t *testing.T) {
const bin, model = "/opt/llama/llama-server", "/models/Qwen3.6-27B-Q4_K_M.gguf"
cfg := map[string]string{"NGL": "999", "CUDA_VISIBLE_DEVICES": "1,0"}
si := serveSysInfo{Help: helpRecent, Model: model}
without, _, _ := buildServeArgs(cfg, nil, bin, si)
si.GPUs = 2
args, env, notes := buildServeArgs(cfg, nil, bin, si)
if !reflect.DeepEqual(args, without) {
t.Fatalf("la ligne de commande a changé\n got %q\nwant %q", args, without)
}
want := map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "CUDA_DEVICE_ORDER": "PCI_BUS_ID",
"CUDA_SCALE_LAUNCH_QUEUES": "4x"}
if !reflect.DeepEqual(env, want) {
t.Fatalf("env = %v, want %v", env, want)
}
// Une note pour 999 → auto, une pour les files de lancement.
if len(notes) != 2 {
t.Fatalf("notes = %q", notes)
}
}
+211
View File
@@ -0,0 +1,211 @@
package loki
import (
"fmt"
"math"
"os"
"strconv"
"strings"
)
// Second slot pour les travaux annexes (SIDE_SLOT, off par défaut).
//
// En --parallel 1, un vérificateur, un sous-agent, une tâche ou un bench
// prennent LE slot de la conversation : son état part dans le cache RAM et
// revient après — quand il y tient encore. Avec SIDE_SLOT=on, le moteur ouvre
// deux slots, la conversation garde le slot 0 et tout le reste passe par le
// slot 1 (id_slot, voir llm_sideslot.go) : l'état principal ne bouge plus du
// tout.
//
// Le dimensionnement est le cœur de l'affaire. Un cache KV UNIFIÉ partagé entre
// deux slots laisse chacun grandir jusqu'à la taille du pool : quand les deux
// travaillent et que le pool est plein, llama.cpp renvoie « Context size has
// been exceeded. » aux DEUX, et Loki prendrait ça pour un débordement de la
// conversation — compaction, voire réduction, d'un fil qui tenait très bien
// dans sa fenêtre. Et --kv-unified-per-slot plafonne tous les slots à la même
// valeur, sans rien garantir sur la somme. On prend donc le cache NON unifié
// (--no-kv-unified) avec -c 2×CTX : llama.cpp découpe alors le pool en deux
// flux de CTX jetons chacun, séparés. Un débordement concurrent est impossible
// par construction, chaque slot a exactement la fenêtre de la conversation (les
// travaux annexes ne sont pas raccourcis), et --cache-idle-slots ne vide plus
// les slots au repos (il ne le fait que sous cache unifié) : rien d'autre à
// régler.
//
// Le prix : le cache KV (et l'état récurrent d'un hybride) en double, en VRAM.
// On refuse donc l'option quand le compte ne tient pas, quand des poids vivent
// en RAM (le KV en plus pousserait le placement automatique à sortir des
// couches du GPU), et dès que l'utilisateur a réglé lui-même l'un des leviers
// en jeu : on ne mélange pas ses réglages et les nôtres.
// sideSlotOn : la clé SIDE_SLOT est-elle posée ?
func sideSlotOn(cfg map[string]string) bool {
switch strings.ToLower(strings.TrimSpace(cfg["SIDE_SLOT"])) {
case "on", "1", "true", "yes", "oui":
return true
}
return false
}
// sideSlotConflictFlags : les drapeaux d'EXTRA_ARGS qui décident déjà de ce que
// SIDE_SLOT règle. Un seul suffit à refuser.
var sideSlotConflictFlags = []string{
"-c", "--ctx-size",
"-np", "--parallel",
"-kvu", "--kv-unified", "-no-kvu", "--no-kv-unified",
"--kv-unified-per-slot",
"--cache-idle-slots", "--no-cache-idle-slots",
}
// sideSlotArgEnv : les mêmes leviers posés par variable. La ligne de commande
// les écraserait sans rien dire : un choix de l'utilisateur, on s'abstient.
var sideSlotArgEnv = []string{"LLAMA_ARG_N_PARALLEL", "LLAMA_ARG_KV_UNIFIED", "LLAMA_ARG_KV_UNIFIED_PER_SLOT",
"LLAMA_ARG_CACHE_IDLE_SLOTS", "LLAMA_ARG_CTX_SIZE"}
// probeSideSlotEnv complète ArgEnv avec sideSlotArgEnv, seulement si la clé est
// posée : sans elle, rien n'est lu.
func probeSideSlotEnv(cfg map[string]string, si *serveSysInfo) {
if !sideSlotOn(cfg) {
return
}
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range sideSlotArgEnv {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
}
// sideSlotPlan décide de SIDE_SLOT pour ce lancement. ctx > 0 : accepté, chaque
// slot aura ctx jetons (le pool en fait le double) ; extraMiB est la VRAM de
// plus, estimée. ctx == 0 : refusé (why dit pourquoi), ou clé absente (why vide)
// — la ligne de commande est alors celle d'avant, à l'octet près.
func sideSlotPlan(cfg map[string]string, extra []string, si serveSysInfo) (ctx int, extraMiB int64, why string) {
if !sideSlotOn(cfg) {
return 0, 0, ""
}
if isExternalConfig(cfg) {
return 0, 0, "preset externe"
}
if p := strings.TrimSpace(cfg["PARALLEL"]); p != "" && p != "2" {
return 0, 0, "PARALLEL=" + p + " (SIDE_SLOT ouvre exactement 2 slots)"
}
for _, f := range sideSlotConflictFlags {
if hasAnyFlag(extra, f) {
return 0, 0, f + " est réglé dans EXTRA_ARGS"
}
}
for _, k := range sideSlotArgEnv {
if si.ArgEnv[k] != "" {
return 0, 0, k + " est posée"
}
}
if !strings.Contains(si.Help, "--no-kv-unified") {
return 0, 0, "ce moteur ne connaît pas --no-kv-unified"
}
n, err := strconv.Atoi(strings.TrimSpace(cfg["CTX"]))
if strings.TrimSpace(cfg["CTX"]) == "" {
n, err = 32768, nil
}
if err != nil || n <= 0 {
return 0, 0, "CTX doit être un nombre de jetons explicite"
}
extraMiB, why = sideSlotVRAM(cfg, extra, si, n)
if why != "" {
return 0, 0, why
}
return n, extraMiB, ""
}
// sideSlotVRAM estime la VRAM du second slot (cache KV de ctx jetons et état
// récurrent) et vérifie que modèle + deux états tiennent dans 90 % de la VRAM,
// la même marge que cacheRAMAuto. Inconnu = refus : on ne double pas le cache
// KV à l'aveugle, c'est exactement la panne de mémoire qu'on a déjà vécue.
func sideSlotVRAM(cfg map[string]string, extra []string, si serveSysInfo, ctx int) (extraMiB int64, why string) {
switch {
case si.VRAMMiB <= 0:
return 0, "VRAM NVIDIA inconnue (macOS, AMD, --device…)"
case si.GGUF == nil:
return 0, "métadonnées GGUF illisibles"
case si.ModelBytes <= 0:
return 0, "taille du modèle inconnue"
}
if r := cpuWeights(cfg, extra, si.ArgEnv, si.GGUF.BlockCount); r != "" {
return 0, r
}
kt, vt, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
perTok, rec := ggufStateBytes(*si.GGUF, kt, vt)
if perTok <= 0 {
return 0, "taille d'état inconnue"
}
state := perTok*float64(ctx) + float64(rec)
if float64(si.ModelBytes)+2*state > 0.9*float64(si.VRAMMiB)*(1<<20) {
return 0, fmt.Sprintf("VRAM insuffisante : modèle + 2 × %d Mio d'état dépassent 90 %% de %d Mio",
int64(math.Ceil(state/(1<<20))), si.VRAMMiB)
}
return int64(math.Ceil(state / (1 << 20))), ""
}
// sideSlotNote : ce que le lancement dit de SIDE_SLOT, accepté ou refusé.
func sideSlotNote(ctx int, extraMiB int64, why string, nglForced bool) string {
if ctx <= 0 {
if why == "" {
return ""
}
return "SIDE_SLOT refusé (" + why + ") : un seul slot, comme sans la clé"
}
n := fmt.Sprintf("SIDE_SLOT : 2 slots de %d jetons (-c %d, --no-kv-unified), ~%d Mio de VRAM en plus "+
"pour le second (cache KV et état récurrent ; le brouillon MTP éventuel et les points de reprise en RAM hôte "+
"doublent aussi)", ctx, 2*ctx, extraMiB)
if nglForced {
return n + " ; NGL est imposé : surveille la VRAM au premier long prompt"
}
return n + " ; le placement automatique sortira des couches du GPU s'il manque de place"
}
// sideSlotPreview : l'avis de SIDE_SLOT pour l'éditeur de preset, calculé comme
// au lancement, à l'aide du moteur près (supposée connaître --no-kv-unified :
// un moteur trop ancien sera refusé au lancement, et le journal le dira).
func sideSlotPreview(content string) (on bool, extraMiB int64, why string) {
cfg := parseEnv(content)
if !sideSlotOn(cfg) {
return false, 0, ""
}
if isExternalConfig(cfg) {
return false, 0, "preset externe"
}
extra := splitArgs(cfg["EXTRA_ARGS"])
si := serveSysInfo{Help: "--no-kv-unified", ArgEnv: map[string]string{}}
for _, k := range append(append(append(append([]string{}, serveArgEnv...), fidelityArgEnv...), cacheArgEnv...), sideSlotArgEnv...) {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
if m := strings.TrimSpace(cfg["MODEL"]); m != "" {
if p, err := resolveServeModelPath(m); err == nil {
si.Model = p
}
}
if si.Model == "" {
return false, 0, "modèle introuvable"
}
probeCacheRAM(cfg, extra, &si)
ctx, mib, why := sideSlotPlan(cfg, extra, si)
return ctx > 0, mib, why
}
// nglForced : le preset impose-t-il un nombre de couches GPU ? Alors le
// placement automatique ne tourne pas, et un second état de trop finit en
// panne de mémoire plutôt qu'en couches sorties du GPU. 999 est la sentinelle
// « toutes », traduite en auto sur un moteur récent (voir nglArgs).
func nglForced(cfg map[string]string, extra []string) bool {
v := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
if v == "" {
v = strings.TrimSpace(cfg["NGL"])
if v == "999" {
return false
}
}
return v != "" && !strings.EqualFold(v, "auto")
}
+147
View File
@@ -0,0 +1,147 @@
package loki
import (
"reflect"
"strings"
"testing"
)
// helpSide : un moteur qui connaît le cache KV non unifié.
const helpSide = helpCacheRAM + `-kvu, --kv-unified, -no-kvu, --no-kv-unified
use single unified KV buffer shared across all sequences
`
// sideCfg : le preset de denseGPU (0,25 Mio d'état par jeton) à 32k, avec ou
// sans SIDE_SLOT.
func sideCfg(kv ...string) map[string]string {
cfg := map[string]string{"CTX": "32768", "NGL": "999"}
for i := 0; i+1 < len(kv); i += 2 {
cfg[kv[i]] = kv[i+1]
}
return cfg
}
func sideSI() serveSysInfo {
si := denseGPU()
si.Help = helpSide
return si
}
// Sans la clé (ou à off), la ligne de commande est celle d'avant, à l'octet
// près, notes comprises.
func TestSideSlotDefautIdentique(t *testing.T) {
for _, v := range []string{"", "off", "0"} {
cfg := sideCfg()
if v != "" {
cfg["SIDE_SLOT"] = v
}
got, _, notes := buildServeArgs(cfg, nil, "/bin/llama-server", sideSI())
want, _, wantNotes := buildServeArgs(sideCfg(), nil, "/bin/llama-server", sideSI())
if !reflect.DeepEqual(got, want) || !reflect.DeepEqual(notes, wantNotes) {
t.Fatalf("SIDE_SLOT=%q change la ligne :\n%v\n%v", v, got, want)
}
if argValue(got, "--parallel") != "1" || argValue(got, "-c") != "32768" {
t.Fatalf("défaut : %v", got)
}
for _, a := range got {
if a == "--no-kv-unified" {
t.Fatal("--no-kv-unified sans la clé")
}
}
}
}
// Accepté : deux slots de CTX jetons chacun, flux KV séparés — le pool fait
// le double, aucun slot ne peut déborder sur l'autre.
func TestSideSlotAccepte(t *testing.T) {
for _, cfg := range []map[string]string{sideCfg("SIDE_SLOT", "on"), sideCfg("SIDE_SLOT", "on", "PARALLEL", "2")} {
args, _, notes := buildServeArgs(cfg, nil, "/bin/llama-server", sideSI())
if argValue(args, "-c") != "65536" || argValue(args, "--parallel") != "2" {
t.Fatalf("args = %v", args)
}
n := 0
for i, a := range args {
if a == "--no-kv-unified" {
n++
if args[i-1] != "2" || args[i-2] != "--parallel" {
t.Fatalf("--no-kv-unified mal placé : %v", args)
}
}
if a == "--kv-unified-per-slot" || a == "-kvu" || a == "--kv-unified" {
t.Fatalf("drapeau inattendu %s", a)
}
}
if n != 1 {
t.Fatalf("--no-kv-unified × %d", n)
}
found := false
for _, s := range notes {
if strings.HasPrefix(s, "SIDE_SLOT : 2 slots de 32768 jetons") && strings.Contains(s, "~8192 Mio") {
found = true
}
}
if !found {
t.Fatalf("note de VRAM absente : %q", notes)
}
}
}
// Refusé : la ligne est exactement celle sans la clé, plus une note qui dit
// pourquoi.
func TestSideSlotRefus(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
why string
}{
{name: "PARALLEL=3", cfg: sideCfg("SIDE_SLOT", "on", "PARALLEL", "3"), why: "PARALLEL=3"},
{name: "-c dans EXTRA_ARGS", extra: []string{"-c", "16384"}, why: "-c "},
{name: "--ctx-size=", extra: []string{"--ctx-size=16384"}, why: "--ctx-size"},
{name: "-np", extra: []string{"-np", "4"}, why: "-np"},
{name: "-kvu", extra: []string{"-kvu"}, why: "-kvu"},
{name: "--no-kv-unified", extra: []string{"--no-kv-unified"}, why: "--no-kv-unified"},
{name: "--kv-unified-per-slot", extra: []string{"--kv-unified-per-slot", "8192"}, why: "--kv-unified-per-slot"},
{name: "--cache-idle-slots", extra: []string{"--cache-idle-slots"}, why: "--cache-idle-slots"},
{name: "--no-cache-idle-slots", extra: []string{"--no-cache-idle-slots"}, why: "--no-cache-idle-slots"},
{name: "variable LLAMA_ARG_N_PARALLEL", si: func(si *serveSysInfo) { si.ArgEnv["LLAMA_ARG_N_PARALLEL"] = "4" }, why: "LLAMA_ARG_N_PARALLEL"},
{name: "variable LLAMA_ARG_KV_UNIFIED", si: func(si *serveSysInfo) { si.ArgEnv["LLAMA_ARG_KV_UNIFIED"] = "1" }, why: "LLAMA_ARG_KV_UNIFIED"},
{name: "moteur sans --no-kv-unified", si: func(si *serveSysInfo) { si.Help = helpCacheRAM }, why: "--no-kv-unified"},
{name: "CTX=0", cfg: sideCfg("SIDE_SLOT", "on", "CTX", "0"), why: "CTX"},
{name: "VRAM trop juste à 64k", cfg: sideCfg("SIDE_SLOT", "on", "CTX", "65536"), why: "VRAM insuffisante"},
{name: "VRAM inconnue", si: func(si *serveSysInfo) { si.VRAMMiB = 0 }, why: "VRAM"},
{name: "GGUF illisible", si: func(si *serveSysInfo) { si.GGUF = nil }, why: "GGUF"},
{name: "experts sur CPU", extra: []string{"--n-cpu-moe", "20"}, why: "CPU"},
{name: "-ot sur CPU", extra: []string{"-ot", "exps=CPU"}, why: "CPU"},
{name: "NGL partiel", cfg: sideCfg("SIDE_SLOT", "on", "NGL", "30"), why: "couches GPU"},
}
for _, c := range cases {
cfg := c.cfg
if cfg == nil {
cfg = sideCfg("SIDE_SLOT", "on")
}
si := sideSI()
if c.si != nil {
c.si(&si)
}
_, _, why := sideSlotPlan(cfg, c.extra, si)
if !strings.Contains(why, c.why) {
t.Errorf("%s : why = %q, attendu %q", c.name, why, c.why)
}
got, _, notes := buildServeArgs(cfg, c.extra, "/bin/llama-server", si)
off := map[string]string{}
for k, v := range cfg {
if k != "SIDE_SLOT" {
off[k] = v
}
}
want, _, wantNotes := buildServeArgs(off, c.extra, "/bin/llama-server", si)
if !reflect.DeepEqual(got, want) {
t.Errorf("%s : refus qui change la ligne\n%v\n%v", c.name, got, want)
}
if len(notes) != len(wantNotes)+1 {
t.Errorf("%s : notes %q", c.name, notes)
}
}
}
Loaded 100 of 669 files, more files were not shown because too many files have changed in this diff. Show more