TypeSafe a sorti Jev et tout mon fil d’actu a pété un câble. Ils appellent ça un modèle System One: tu lui files un état en vrac plus quelques questions typées, et il te rend un choix, un score ou un oui/non avec une vraie probabilité dessus. Pas de dissertation, pas de “Bien sûr! Voici le JSON demandé:”, pas besoin de reparser de la prose pour retrouver le seul mot que tu voulais. Faut leur reconnaître ça, c’est la bonne putain d’idée.
Et puis tu lis les petites lignes. API hébergée. Accès anticipé. Liste d’attente. Pas de poids. Et le truc que tu voudrais faire juger, c’est par définition le truc sensible, “cet agent est sur le point de faire un DROP sur la table customers”, donc il doit sortir de ton réseau et poireauter dans la file d’attente de quelqu’un d’autre avant que tu aies ton oui ou ton non. Hors de question.
Alors j’ai refait ce que j’avais déjà fait avec talkies pour la voix, flickies pour la vidéo et predictalot pour les prévisions. J’ai passé en revue les modèles ouverts qui font ce boulot, gardé les deux meilleurs, Laya et Von, et je les ai cloués dans une seule image Docker derrière une seule API. Ça, c’est decidealot, le Jev hors ligne: la même forme de requête et de réponse System One, du MCP sur le même port, ton matériel, pas de facture cloud, pas de liste d’attente, et le truc sur lequel tu poses ta question ne quitte jamais ta machine. Ensuite j’ai fourré decidealot dans aigate, juste à côté de ses frangins, donc si tu fais déjà tourner cette stack, tu es à une variable d’env de l’avoir.
Demander une Décision à un Générateur de Texte, C’est Con Comme un Balai
Jev a cartonné parce que le statu quo est débile à pleurer:
- Les LLM comme classifieurs. Un modèle conçu pour générer du texte, qui génère du texte, que tu reparses ensuite pour en extraire le seul mot que tu voulais. Tu supplies pour avoir “du JSON valide UNIQUEMENT, sans explication” et tu le récupères emballé dans des balises de code markdown avec une gentille petite note sur son raisonnement, sans compter la fois sur cinquante où le modèle invente une quatrième catégorie que tu n’as jamais listée. “Réponds uniquement en JSON”, ce n’est pas un contrat, c’est une prière, et les modes de sortie structurée ne font que déplacer le parsing dans le sampler de quelqu’un d’autre. Tu paies toujours un décodage token par token pour produire une étiquette.
- La confiance autodéclarée. Demande à un chatbot à quel point il est sûr de lui et il te sort “85%”, un chiffre tiré de son cul sans sourciller, parce qu’un chiffre, ça rendait bien à cet endroit. Ce chiffre n’a jamais vu un softmax, même de loin. Tu ne peux pas mettre de seuil dessus, tu ne peux pas le calibrer, et tu ne peux pas le coller dans un journal d’audit pour le défendre plus tard.
- De la latence cramée pour rien. Chaque “Certainement! D’après le contexte fourni”, c’est du temps d’horloge planté entre ton événement et ta décision.
- Les modèles à poids ouverts. Les deux meilleurs qui font ce boulot en local sont Laya de NandhaKishorM et Von de wfzyx, poids sous Apache 2.0. Super. Chacun débarque avec son propre serveur qui a ses propres idées sur la gueule d’une requête, sa propre install et son propre runtime Torch, et aucun des deux n’accepte la requête TypeSafe officielle telle quelle. Tu veux les deux, et te voilà avec deux serveurs, deux installs et deux runtimes Torch, plus la colle à écrire toi-même.
Je voulais un seul truc que je lance une fois et vers lequel je pointe tout. Le contrat de l’API hébergée, pour que le code écrit contre ce contrat n’ait pas à apprendre une deuxième API. Les deux modèles locaux derrière. Du MCP pour les agents. Et pas un modèle chargé plus tout un runtime Torch qui squattent la RAM pendant que personne ne demande que dalle.
Trois Types de Question, Une Seule Requête
Le contrat est petit. Tu envoies un model, un state, et une map de questions nommées. L’état, c’est ce que tu veux faire juger: une chaîne, un objet JSON ou un tableau. Les noms des questions, c’est toi qui les choisis, et ils reviennent comme clés sous answers. Chaque question est d’un de ces trois types:
choicechoisit une étiquette parmi les clés decriteria. Ces clés sont les seules réponses possibles. Le modèle ne peut pas inventer une quatrième case, parce qu’il n’écrit pas le moindre putain de mot. Tu récupères lechoice, uneconfidence, et une probabilité pour chaque étiquette.scoreprend un tableau ordonné de critères où la position est le score, en partant de 0. Tu récupères un score espéré, donc 1.9 sur une grille à trois niveaux est une vraie réponse qui veut dire “bloquant, avec un soupçon de bientôt”, plus les probabilités par niveau et unelegendqui fait correspondre les positions à ta propre formulation.noul, c’est oui ou non. Un seul champ,noul, la probabilité que l’affirmation soit vraie. Pas de champ de confiance séparé, parce que ce nombre est déjà la confiance.
Mets les trois dans une seule requête et elles reçoivent toutes leur réponse à partir du même état:
curl --fail http://127.0.0.1:8080/v1/systemone \
--header 'Content-Type: application/json' \
--data '{
"model": "laya",
"state": "You billed me twice for March. Refund the duplicate today or I am cancelling.",
"questions": {
"department": {
"type": "choice",
"instructions": "Which team should handle this?",
"criteria": {
"billing": "Invoices, payments, refunds.",
"technical": "Bugs, outages, errors.",
"other": "Everything else."
}
},
"urgency": {
"type": "score",
"criteria": ["not urgent", "soon", "blocking"]
},
"churn_risk": {
"type": "noul",
"instructions": "Does the customer threaten to leave?"
}
}
}'Ce n’est pas un exemple inventé. Voilà ce que Laya a réellement renvoyé, en tournant sur l’image CUDA derrière mon propre aigate:
{
"model": "laya",
"answers": {
"department": {
"type": "choice",
"choice": "billing",
"confidence": 0.8055,
"probabilities": { "billing": 0.9543, "technical": 0.0317, "other": 0.014 }
},
"urgency": {
"type": "score",
"score": 1.8986,
"confidence": 0.7053,
"legend": { "0": "not urgent", "1": "soon", "2": "blocking" },
"probabilities": { "0": 0.0222, "1": 0.0571, "2": 0.9208 }
},
"churn_risk": {
"type": "noul",
"noul": 0.2673
}
},
"usage": { "input_tokens": 156, "output_tokens": 0 }
}Regarde output_tokens. Zéro. Laya n’a pas écrit un seul token, donc rien ne peut revenir sous la forme “Bien sûr! Voici”. La même requête envoyée à Von en déclare trois, ce qui reste à des années-lumière d’un paragraphe. Dans les deux cas, la forme de la réponse est fixée par le schéma, pas par le fait que le modèle en ait eu quelque chose à foutre de tes instructions aujourd’hui.
Maintenant regarde churn_risk. Le client a littéralement écrit “or I am cancelling” et Laya a évalué la menace à 0.27. J’ai envoyé la même requête à Von pour avoir un deuxième avis, et Von a dit 0.23. Billing et blocking, les deux en plein dans le mille. La menace de résiliation, les deux s’en sont tapé. Ce n’est pas decidealot qui a massacré quoi que ce soit, c’est ce que les modèles ont répondu, et c’est toute la raison d’être du paragraphe suivant. Teste tes questions sur tes propres cas avant de brancher un seuil dessus. Reformuler la question ne coûte rien. Le découvrir en prod, si.
decidealot te file les chiffres et s’arrête là. Aucune action. Le seuil appartient à ton code: tu autorises quand allow dépasse 0.95, tu mets tout le reste en file pour un humain, tu stockes la réponse complète à côté de l’enregistrement de l’action. Même posture que predictalot, juste une couche au-dessus. Les chiffres entrent, et la décision de ce qu’on en fait reste la tienne.
Deux Modèles, Dix Sélecteurs, Un Seul en Mémoire
Chaque requête nomme un modèle. Il n’y a pas de défaut, donc tes décisions ne changent pas de cerveau en douce parce qu’un connard a modifié une variable d’env. GET /v1/models renvoie le catalogue, et le voici, les dix:
laya,laya-auto,laya-latest: Laya avec routage automatique de checkpoint. Le routage regarde le système d’écriture et la langue de l’état, puis choisit tout seul le checkpoint anglais ou le multilingue.laya-english: le checkpoint anglais, pour de l’anglais en alphabet latin.laya-multilingual: le checkpoint multilingue, pour tout le reste, y compris les textes courts en alphabet latin qui ne sont pas clairement de l’anglais.laya-typed-decisions: le checkpoint affiné pour les appels structurés répétés dans un workflow, genre politiques, routage, triage et approbations. Teste-le sur tes propres cas avant de lui faire confiance.von,von-latest,von-1.1,von-1.1.0: Von, un modèle indépendant, anglais uniquement, pour des décisions courtes et bien posées. Utile tout seul, et utile comme deuxième avis avant de standardiser un workflow sur Laya.
Laya, c’est une famille de modèles avec trois checkpoints. Von, c’est un autre modèle, fait par d’autres gens. Les deux prennent la même requête et renvoient les mêmes types de réponse, et c’est tout l’intérêt de les mettre derrière un seul contrat: tu changes la chaîne model et tu compares.
Décharger, Ça Veut Dire Tuer le Processus
C’est la partie qui me tient vraiment à cœur. Il y a trois virtualenvs Python dans l’image. /opt/app-venv, c’est la gateway: FastAPI, httpx, le SDK MCP, pydantic, uvicorn. Pas de Torch, pas un seul import de Torch nulle part dans le source de la gateway. /opt/laya-venv et /opt/von-venv contiennent chacun la stack d’un modèle. En ce moment, les deux épinglent par hasard les mêmes versions de torch et de transformers, donc ce n’est pas un contournement pour une guerre déjà en cours. Ça veut dire qu’une mise à jour de Laya ne pourra jamais aller foutre le bordel dans l’environnement de Von, et que le processus qui répond à tes requêtes HTTP n’a jamais un modèle dedans.
Chaque modèle tourne comme processus enfant de la gateway, lancé par un superviseur à partir d’une commande fixe sans shell, en écoute sur un port loopback fixe. Un seul est résident à la fois. Demande Von pendant que le modèle Laya est chargé, et le superviseur attend que chaque requête Laya en cours se termine, tue Laya, démarre Von, et interroge le /health de Von tous les quarts de seconde jusqu’à ce que Von réponde. Passer d’un sélecteur Laya à un autre reste à l’intérieur du processus Laya, puisque ce sont des checkpoints du même truc. En version raccourcie, le verrou ressemble à ça:
async with self._provider_switch_condition:
spec = self._require_spec(provider_name)
while self._has_active_other_provider(provider_name):
await self._provider_switch_condition.wait()
await self._unload_other_idle_providers_locked(provider_name)
await self._start_provider_locked(spec)
self._active_requests[provider_name] += 1
try:
yield
finally:
async with self._provider_switch_condition:
self._active_requests[provider_name] -= 1
self._last_used_at[provider_name] = asyncio.get_running_loop().time()
self._provider_switch_condition.notify_all()Pourquoi un processus entier plutôt qu’un del model et une prière au garbage collector? Parce que ça ne rend pas la mémoire. L’allocateur avec cache de PyTorch garde ce qu’il a pris, et le contexte CUDA reste en place tant que le processus vit. Tuer le processus, c’est le seul déchargement qui en soit vraiment un, donc c’est exactement ce que fait le déchargement: terminate, dix secondes de grâce, puis kill si le processus fait chier. Les poids, les allocations Torch, les threads workers et le contexte CUDA partent tous avec lui. La requête suivante relance le processus.
Ce démarrage n’est pas gratuit, et je ne vais pas faire semblant du contraire. Sur ma machine GPU, via aigate, la première requête Laya après un démarrage à froid a pris environ 61 secondes, parce que le processus devait se lancer et charger le modèle. La requête identique suivante est revenue en 55 millisecondes de bout en bout, réseau compris, avec exactement la même réponse, à l’octet près. Passer à Von a pris environ deux minutes, puisque Laya devait d’abord s’arrêter et Von démarrer. Von, une fois chaud, a répondu en environ 115 millisecondes. Donc le timeout d’inactivité est un vrai compromis: règle-le assez long pour que ton trafic ne se retape pas le démarrage à froid en boucle, et assez court pour que le GPU ne garde pas au chaud un modèle dont personne ne se sert.
Deux choses déclenchent ça. Un reaper d’inactivité décharge un provider resté inutilisé pendant DECIDEALOT_PROVIDER_IDLE_UNLOAD_SECONDS, 600 par défaut, avec une vérification tous les dixièmes de cette fenêtre, intervalle borné entre 10 millisecondes et 30 secondes. Mets cette valeur à 0 et seul le timer se coupe. Et POST /v1/models/unload fait la même chose à la demande. Cette route-là, c’est tout ou rien: si un provider est en plein milieu d’une requête, tu te prends un 409 PROVIDER_BUSY et rien n’est libéré. Elle n’arrache jamais un modèle sous le nez d’un appelant.
Un Seul Téléchargement, Puis Hors Ligne pour de Bon
Tu montes un répertoire de l’hôte sur /models, et decidealot gère /models/laya et /models/von dedans. Au premier démarrage, le superviseur lance une étape prepare pour chaque modèle, dans le venv propre à ce modèle, qui récupère le snapshot Hugging Face, convaiinnovations/laya et wfzyx/von, chacun épinglé sur une révision de commit exacte. Ensuite le superviseur vérifie que chaque fichier dont le runtime a besoin est bien là: quatre fichiers dans chacun des trois répertoires de checkpoint de Laya, six pour Von. Rien de tout ça n’importe Torch. /health répond 503 tant que les deux bundles ne sont pas prêts, soit environ 5.3 GB et quelques minutes la première fois. Après ça, les fichiers sont déjà là et plus rien ne se télécharge.
Puis, avant qu’un modèle ne se charge, decidealot fixe HF_HUB_OFFLINE=1 et TRANSFORMERS_OFFLINE=1. Une fois le bundle vérifié, aucun modèle ne peut repartir faire un tour sur le Hub en plein milieu d’un run pour te ramener une merde que tu n’as pas épinglée.
Forcer Deux Serveurs Amont à Parler le Même Contrat
Le schéma officiel dit que instructions est optionnel et peut être une valeur JSON imbriquée, et pareil pour les valeurs des critères. Laya veut un champ instructions sur chaque question, sans exception. Von veut que ce soit une chaîne. Envoie à l’un ou à l’autre une requête que l’API officielle accepte sans broncher, et tu te fais jeter pour une différence de forme que personne n’a demandée.
Donc decidealot valide d’abord ta requête contre les modèles de requête officiels, puis construit à partir d’elle le body natif de chaque modèle: un instructions absent devient une chaîne vide, et les valeurs imbriquées sont rendues en texte JSON compact. Tes étiquettes de choix, ton ordre de score et ton state passent sans qu’on y touche. Au retour, la réponse du provider est validée contre le schéma de réponse officiel. La camelote propre au provider, comme le bloc de routage de Laya, part à la poubelle, et model reçoit le nom public de ce qui a réellement répondu. Si un provider renvoie quelque chose qui ne rentre pas dans le schéma, tu reçois un 503 propre au lieu d’une merde déguisée en décision. Si un provider rejette une requête avec un simple message, ce message est reformulé dans l’enveloppe de validation officielle {"detail": [...]}, pour que ta gestion d’erreurs ne voie jamais qu’une seule forme.
Et puis il y a le serveur de Von, qui trouve son backend via un singleton à l’échelle du processus, dont le constructeur public n’offre aucun moyen de lui dire où se trouve le checkpoint. Donc decidealot construit le moteur lui-même et l’enfonce de force dans l’emplacement que le serveur lit:
engine = engine_type(
backend_name=os.environ.get(_von_backend_env, _default_von_backend),
device=os.environ.get(_von_device_env),
)
engine.backend = option_marker_backend_type(checkpoint_dir=str(model_dir), device=engine.device)
# Von's server resolves its backend from this singleton, whose public constructor
# has no checkpoint-directory argument.
with engine_type._lock:
engine_type._instance = engineOui, c’est aller fouiller dans un singleton privé. C’est moche à chier, et c’est exactement aussi moche qu’il le faut pour pointer Von sur un répertoire que tu contrôles.
Du MCP sur le Même Port
Le même container sert du MCP Streamable HTTP sur /mcp, avec trois outils: system_one, list_models et unload_models. Ils passent par le même service de décision que les routes REST, donc même validation, même superviseur, même limite de body et même bearer token. system_one prend exactement le model, le state et les questions que tu enverrais en POST, et renvoie le même résultat structuré, pour qu’un agent puisse lire les probabilités avant de décider quoi faire ensuite. Un échec de validation revient avec isError: true et le body detail de TypeSafe dans le texte, pour que l’agent voie où il a merdé au lieu d’un “error” tout sec.
Aucun outil ne prend d’URL, de chemin de fichier, d’exécutable, de répertoire de modèle ou d’option de runtime. Le routeur de modèles associe chaque alias à un endpoint loopback fixe, et un appelant ne choisit jamais de cible réseau. Le plus grand pouvoir qu’un agent ait ici, c’est de choisir un nom dans le catalogue.
Mettre le container derrière un reverse proxy ou un tunnel, ça ne veut pas dire désactiver la protection contre le DNS rebinding. Tu mets en liste blanche le Host public exact dans DECIDEALOT_MCP_ALLOWED_HOSTS et, pour les clients navigateur, l’origine exacte dans DECIDEALOT_MCP_ALLOWED_ORIGINS. Les vérifications tournent dans un ordre fixe: un bearer absent ou faux prend d’abord un 401, puis un host inconnu prend un 421, puis une origine navigateur inconnue prend un 403. Pas de wildcard sur une machine exposée à Internet. Mets un vrai secret dans DECIDEALOT_API_KEY avant que le container s’approche de près ou de loin d’une adresse publique.
Toute la Merde Chiante Qui Permet de Laisser Tourner le Container Sans Flipper
- Un container qui ne peut pas faire grand-chose. Le lancement documenté, c’est un système de fichiers racine en lecture seule,
--cap-drop ALL,no-new-privileges, des tmpfsnoexecpour/tmpet/var/run, une limite de pids, un plafond mémoire, et un port publié uniquement sur loopback. - Ton UID, pas celui de l’image. Le container tourne avec le
--userque tu lui passes, quel qu’il soit, donc le répertoire de modèles que tu viens de créer avecmkdirest accessible en écriture sans le rituel duchown. L’image ne retombe sur un1000:1000non-root que si tu ne précises rien. - Un seul trou exec pour CUDA, et pas un de plus. Triton compile de petits helpers CUDA au runtime et doit bien les charger de quelque part, donc le lancement CUDA ajoute un unique tmpfs
execsur/var/cache. Pour tout le reste, lecture seule etnoexec. - Une auth bearer qui ne fuit pas par le timing. Optionnelle, désactivée tant que
DECIDEALOT_API_KEYn’est pas définie, et la comparaison passe parhmac.compare_digestsur des octets encodés./healthreste ouvert pour ta liveness probe. - Un plafond sur le body. 1 MiB par défaut via
DECIDEALOT_MAX_REQUEST_BYTES, et tout ce qui se déclare plus gros prend un413avant qu’un modèle ne le voie. - Des request IDs que tu peux grepper. Envoie un UUID ou un ULID dans
X-Request-Idet il suit la requête à travers les logs. Envoie de la merde ou rien du tout, et decidealot génère un UUID. Dans tous les cas, l’ID revient dans la réponse. - Un garde-fou supply-chain pour lequel les modèles étaient trop jeunes. Les dépendances de la gateway sont derrière un garde-fou d’âge
exclude-newerde uv, et les stacks des modèles s’installent avec--require-hashesà partir de fichiers verrouillés par hash. Les deux paquets de modèles sont plus récents que ce que le garde-fou autorise. La release épinglée de Laya n’est même pas sur PyPI, donc elle est installée depuis le tarball du commit amont, avec le SHA-256 dans le lock. Chaque paquet porte une exception écrite, approuvée par le propriétaire. Le garde-fou a fait son boulot, et j’ai signé l’autorisation parentale. - Des tests avec un plancher. Couverture de branches avec un minimum dur à 90%, plus de vrais runs HTTP contre les poids CPU et CUDA téléchargés.
Deux images. Celle pour CPU fait à peu près un demi-giga compressée sur Docker Hub, et c’est le bon choix par défaut. Celle pour CUDA fait environ 9 GB, construite sur CUDA 12.6, amd64 uniquement, et demande le NVIDIA Container Toolkit et --gpus all. Ne la sors que quand la vitesse et la mémoire du modèle justifient vraiment le setup GPU.
Lancer le Truc
model_directory="$HOME/.local/share/decidealot/models"
mkdir --parents "$model_directory"
docker run --detach --name decidealot --init --restart unless-stopped \
--user "$(id -u):$(id -g)" \
--read-only --cap-drop ALL --security-opt no-new-privileges:true \
--pids-limit 512 --memory 8g --cpus 4 \
--tmpfs /tmp:rw,noexec,nosuid,size=128m \
--tmpfs /var/run:rw,noexec,nosuid,size=8m \
--mount type=bind,source="$model_directory",target=/models \
--publish 127.0.0.1:8080:8080 \
psyb0t/decidealot:latest
curl --fail http://127.0.0.1:8080/healthAttends que /health passe au vert, puis envoie au container la requête de plus haut. Pour le GPU, ajoute --gpus all, le tmpfs /var/cache, et utilise psyb0t/decidealot:latest-cuda. La doc de déploiement dans le repo a la recette CUDA complète.
Si tu fais déjà tourner aigate, zappe toute cette merde. Mets DECIDEALOT=1 pour le service CPU sur /decidealot/ ou DECIDEALOT_CUDA=1 pour le service GPU sur /decidealot-cuda/, avec du MCP sous chacun, et les outils rejoignent aussi le /mcp/ agrégé d’aigate, à côté de tout ce à quoi tes agents parlent déjà. Les deux peuvent tourner côte à côte et partager un seul répertoire de modèles, donc les 5.3 GB n’atterrissent qu’une fois sur le disque.
aigate fait aussi la police dans la mémoire à ta place. Avant que n’importe quel autre modèle local (Ollama, sd.cpp, talkies, vLLM, llama.cpp) tourne sur le même matériel, le gestionnaire de ressources d’aigate dit à decidealot de décharger. Les décisions qui arrivent par le MCP d’aigate prennent le même verrou matériel que tout le reste, et POST /v1/unload/cuda ou /v1/unload/cpu libère decidealot en même temps que le reste de la stack. Une décision en cours répond 409 et garde son modèle jusqu’au timer d’inactivité, donc rien n’est arraché sous le nez d’un appelant. Le seul trou: une requête envoyée directement à /decidealot/ n’évince personne d’autre, donc sur ce chemin-là, c’est toujours toi qui jongles avec le GPU.
Le Brancher sur Ton Agent
Un agent qui s’apprête à agir sur une probabilité devrait au moins savoir ce que cette probabilité veut dire. Le skill lui explique comment déployer le container, choisir Laya ou Von, envoyer une décision typée, lire les probabilités sans les prendre pour une permission, et utiliser le MCP directement. Tout ce qui est sous .agents/ est catalogué dans un seul marketplace, donc ça fait deux commandes:
claude plugin marketplace add psyb0t/agents
claude plugin install decidealot@psyb0tCodex utilise le même marketplace avec un autre verbe, codex plugin add decidealot@psyb0t, parce que codex plugin install n’existe pas. OpenClaw reçoit le skill, plus un pont stdio optionnel pour les clients qui ne savent parler qu’à un serveur MCP stdio local, et ce pont relaie vers le container que tu fais déjà tourner:
openclaw skills install @psyb0t/decidealot
openclaw plugins install clawhub:@psyb0t/decidealotdecidealot Décide. Toi, Tu Agis.
Jev avait la bonne idée, mais hébergée chez quelqu’un d’autre. decidealot, c’est la même idée, chez toi: un ensemble fermé de réponses, une vraie probabilité sur chacune, et zéro chance de récupérer une dissertation. decidealot ne sait pas quel devrait être ton seuil, et ne fait pas semblant de le savoir. Tu choisis la limite en fonction de ce que ça te coûte de te planter, et le modèle n’a jamais son mot à dire là-dessus.
Récupère decidealot sur github.com/psyb0t/decidealot ou fais un pull de psyb0t/decidealot depuis Docker Hub. Le code est sous WTFPL, donc fais-en ce que tu veux, bordel. Laya, Von, PyTorch, Transformers et les poids téléchargés gardent tous leurs propres licences, alors lis-les avant de visser un modèle dans un truc que tu vends. Maintenant va, et arrête de demander oui ou non à des chatbots.