Aller au contenu

Architecture

Lemniscate se lit en deux moitiés qui ne se ressemblent pas. Sur le poste de développement, une même logique métier est enveloppée de trois façons — deux extensions d’IDE et un programme de terminal. Sur l’infrastructure du client, trois services indépendants tiennent les clés des fournisseurs de modèles et les plafonds de dépense.

La séparation n’est pas décorative : elle décide de ce qui traverse le pare-feu, et donc de ce qu’un administrateur a à ouvrir et à surveiller.

flowchart TD
subgraph poste["Poste de développement"]
vscode["Extension VS Code"]
jetbrains["Plugin JetBrains"]
cli["CLI lemni"]
gui["gui — interface React"]
core["core — logique métier"]
end
subgraph site["Hébergé par le client"]
passerelle["llm-gateway"]
base[("gateway-db")]
console["gateway-admin"]
end
fournisseurs["Fournisseurs de modèles"]
vscode -->|webview| gui
jetbrains -->|navigateur embarqué JCEF| gui
vscode -->|même processus| core
jetbrains -->|sous-processus, messages JSON| core
cli -->|lié à la construction| core
core -->|requêtes de modèles| passerelle
passerelle -->|configuration lue, appels journalisés| base
console -->|lecture et écriture| base
passerelle -->|clé du fournisseur injectée| fournisseurs

Tout ce qui décide vit dans core/ : dialogue avec les modèles (core/llm), outils que l’agent peut appeler (core/tools), fournisseurs de contexte (core/context), indexation (core/indexing), chargement de la configuration (core/config), et le protocole de messages qui relie le tout (core/protocol).

Aucune de ces décisions n’est réimplémentée par les enveloppes. Ce qui change d’une enveloppe à l’autre, c’est la façon dont core est hébergé :

Enveloppe Hébergement de core
VS Code dans le processus de l’extension, par un messager en mémoire
JetBrains dans un sous-processus séparé, dialogue en JSON sur l’entrée et la sortie standard
CLI lemni lié au programme à la construction

Côté VS Code, VsCodeExtension.ts instancie core derrière un InProcessMessenger : le même appel de fonction, sans franchir de frontière de processus.

Côté JetBrains, la contrainte est différente — le plugin est en Kotlin et ne peut pas charger du TypeScript. core est donc empaqueté séparément dans binary/, que CoreMessenger.kt lance comme sous-processus et à qui il envoie des messages JSON portant un messageId, un messageType et une charge utile. Les réponses reviennent par le même canal, et celles qui concernent l’interface sont réémises vers le navigateur embarqué. Une variable d’environnement USE_TCP bascule ce canal sur une connexion TCP — c’est un chemin de débogage, pas un mode de fonctionnement.

Côté CLI, la construction d’extensions/cli résout core/ et packages/* par alias : le programme publié contient la logique, il ne la charge pas depuis ailleurs.

gui/ est une application React. Ce n’est pas une bibliothèque de composants : c’est l’écran complet du produit dans l’IDE — le chat, l’agent, l’historique, la page de réglages.

Les deux extensions l’embarquent, chacune à sa manière. L’empaquetage de l’extension VS Code construit gui/ et vérifie la présence du bundle avant de produire le VSIX ; le plugin JetBrains sert le même bundle depuis ses ressources, dans un navigateur embarqué JCEF.

L’interface sait dans quel IDE elle tourne parce que la page qui la charge le lui écrit : la page servie côté JetBrains pose ide à jetbrains dans le stockage local, et gui lit cette valeur pour adapter les raccourcis clavier et quelques écrans. C’est le seul endroit où les deux intégrations divergent visiblement.

Le CLI n’utilise pas gui : son interface est celle du terminal.

Les trois composants serveur sont livrés au client et tournent chez lui. Ils sont distincts, et cette séparation porte une garantie.

Un serveur Hono qui déclare une seule route : ALL /:endpoint/*. Le premier segment du chemin nomme un endpoint configuré en base ; tout le reste — chemin et chaîne de requête — est réémis vers l’URL de base du fournisseur associé.

Avant de relayer, la passerelle enchaîne des contrôles dans un ordre qui est un contrat : authentification de l’appelant, résolution de l’endpoint, présence d’un plan, quota mensuel, clé du fournisseur, puis relais. Un endpoint inconnu est refusé sans que le quota soit consulté, et aucun chemin qui atteint un fournisseur n’échappe au contrôle de quota.

Elle n’impose aucun schéma au corps de la requête. Un corps qui se parse en JSON voit son champ model remplacé par celui de l’endpoint ; un corps qu’elle ne sait pas parser part inchangé. Les flux en text/event-stream sont relayés au fil de l’eau.

Une base PostgreSQL. Elle porte les comptes et le condensat de leur clé d’API, les plans et leur plafond mensuel, les fournisseurs, les modèles et leur tarif, les endpoints publiés, et request_logs — une ligne par requête relayée.

Ce qu’elle ne porte pas : les clés des fournisseurs. Pour chaque fournisseur, elle enregistre le nom de la variable d’environnement qui porte la clé et l’en-tête HTTP dans lequel la poser. La clé elle-même n’existe que dans l’environnement du processus llm-gateway.

Une application React servie par son propre serveur, avec quatre onglets — les comptes (Users), les plans (Plans), les endpoints (Endpoints) et la consommation (Usage). C’est par elle que passent la création d’un compte, la rotation d’une clé, la déclaration d’un fournisseur ou d’un modèle, et la publication d’un endpoint.

La console écrit directement dans la base, avec sa propre chaîne de connexion. Elle ne passe jamais par la passerelle, et la passerelle ne lui parle pas. Le couplage entre les deux services est la base, rien d’autre.

Conséquence pratique : comme la passerelle relit la base à chaque requête, sans cache, un changement fait dans la console prend effet sur la requête suivante. Rien à redémarrer.

Une partie du code du poste sait parler à un service de plan de contrôle hérité du projet dont Lemniscate est issu. Ce service n’est pas opéré, et les chemins qui en dépendent n’aboutissent pas — voir Ce qui n’existe pas.

Le raisonnement qui justifie le passage par la passerelle plutôt qu’un appel direct du poste au fournisseur est développé dans Le rôle de la passerelle. Ce qui sort du poste, et comment le vérifier, est dans Où passent les données.