decidealot: Arrête de Faire Pondre une Dissertation à un Chatbot Quand Il te Fallait Juste un Putain de Oui ou Non

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 meilleurs, Laya et Von, ajouté plus tard CLM en troisième, et je les ai tous 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 sur les modèles locaux le truc sur lequel tu poses ta question ne quitte jamais ta machine. File-lui une clé TypeSafe et il te sert aussi le vrai Jev hébergé, la seule route où ton état sort pour de bon. 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. Tous les modèles derrière, chacun avec son interrupteur. 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:

  • choice choisit une étiquette parmi les clés de criteria. 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 le choice, une confidence, et une probabilité pour chaque étiquette.
  • score prend 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 une legend qui 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.

Trois Modèles Locaux Plus Jev Hébergé, Chacun Son Interrupteur

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 de ce que ce déploiement a activé. Avec les trois providers locaux activés, ce sont les quatorze ci-dessous, plus les noms que TypeSafe te renvoie si tu actives aussi Jev en version hébergée:

  • 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.
  • clm, clm-latest, clm-0.1, clm-0.1-8b: CLM v0.1, foutu d’une tout autre façon que les deux autres. J’y reviens juste en dessous.

Laya, c’est une famille de modèles avec trois checkpoints. Von, c’est un autre modèle, fait par d’autres gens. CLM, que j’ai rajouté plus tard, c’est encore une autre bête: une projection head de 75 MB signée Contrastive-LM, incapable de lire un seul mot par elle-même. CLM envoie ton état et tes critères, rendus en texte, à un unique endpoint /v1/embeddings compatible OpenAI, qui doit lui renvoyer des vecteurs Qwen3-8B du dernier token larges d’exactement 4096 floats, jette tout vecteur qui n’a pas exactement cette largeur, normalise les autres avant même que la head tourne, et note chaque réponse face à l’état à partir de ces vecteurs. Les textes de réponse déjà passés en embeddings sortent d’un cache borné avec TTL au lieu de retaper l’endpoint, mais ton état brut n’est jamais mis en cache et repart en embeddings à chaque fois. Les trois 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.

Chacun des trois locaux a son propre interrupteur: DECIDEALOT_LAYA_ENABLED, DECIDEALOT_VON_ENABLED et DECIDEALOT_CLM_ENABLED. Fais tourner les trois, deux au choix ou un seul, mais il faut au moins un provider d’allumé, parce qu’une machine sans aucun provider refuse de démarrer au lieu de rester plantée là à ne répondre à rien. Laya et Von sont activés par défaut. CLM est sur auto par défaut, ce qui l’allume dès que tu mets DECIDEALOT_CLM_EMBEDDINGS_URL. Mets-le à true et une URL manquante fait planter le démarrage, mets-le à false et CLM reste éteint même avec une URL. C’est l’URL qui mérite réflexion: ton état part vers cet endpoint, donc CLM est exactement aussi privé que ce qui se cache derrière. Branche CLM sur un truc que tu fais tourner toi-même, ou au moins sur un truc auquel tu fais confiance. Un provider éteint n’est pas téléchargé, ne démarre pas, n’apparaît ni dans GET /v1/models ni dans la liste de modèles du MCP, et ses noms sont refusés. Une machine CLM-only télécharge l’unique checkpoint de CLM et rien d’autre. Et oui, il y a maintenant un quatrième interrupteur, pour le Jev hébergé à qui ce post fait un doigt d’honneur depuis la première ligne. DECIDEALOT_JEV_ENABLED est lui aussi sur auto par défaut, ce qui l’allume dès que tu mets DECIDEALOT_TYPESAFE_API_KEY. Ses noms de modèles sortent tout droit du catalogue de TypeSafe, gardé en cache 60 secondes puis redemandé à la première requête d’après, sans aucun alias local jev, et chaque décision Jev envoie tout ton état et toutes tes questions chez TypeSafe. C’est exactement l’inverse du offline, donc si c’est pas ce que tu veux, laisse la clé vide et Jev n’existe pas. Avec la clé, tu peux même éteindre les trois locaux et faire tourner une machine Jev-only qui ne télécharge rien.

Décharger, Ça Veut Dire Tuer le Processus

C’est la partie qui me tient vraiment à cœur. Il y a deux virtualenvs Python dans l’image. /opt/app-venv, c’est la gateway: FastAPI, httpx, le SDK MCP, pydantic, uvicorn. Pas de Torch là-dedans, et la gateway ne l’importe jamais. /opt/torch-venv, c’est le côté modèles, partagé par Laya, Von et CLM: les paquets des modèles s’installent depuis un seul fichier verrouillé par hash, par-dessus mes images Torchbase, qui existent pour que les grosses couches Python, Torch et CUDA soient construites une fois et réutilisées, au lieu d’être reconstruites chaque fois que je touche une ligne du code de l’app. Cela dit, partager un venv, ce n’est pas partager un processus. 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. Par défaut, un seul est résident à la fois, et DECIDEALOT_MAX_RESIDENT_LOCAL_PROVIDERS monte ça à deux ou trois si t’as la RAM ou la VRAM qui va avec. Avec le réglage par défaut, 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. Avec plus de slots, il ne tue quelque chose qu’une fois qu’ils sont tous pris, et dans ce cas c’est le modèle inactif utilisé il y a le plus longtemps. Le Jev hébergé ne touche à rien de tout ça, c’est juste un appel HTTPS. 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 (
        provider_name not in self._processes
        and len(self._processes) >= self._settings.max_resident_local_providers
    ):
        idle_candidates = [
            name for name in self._processes if self._active_requests[name] == 0
        ]
        if idle_candidates:
            oldest = min(idle_candidates, key=lambda name: self._last_used_at.get(name, 0))
            await self._stop_provider_locked(oldest, reason="model_switch")
            continue
        await self._provider_switch_condition.wait()
    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 local 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, /models/von et, si CLM est activé, /models/clm dedans. Au premier démarrage, le superviseur lance une étape prepare pour chaque modèle activé, dans le venv Torch, 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. CLM ne récupère qu’un seul fichier depuis Contrastive-LM/CLM-v0.1-8B, lui aussi sur une révision épinglée, et ne s’en sert que si son SHA-256 correspond à celui épinglé. Rien de tout ça n’importe Torch. /health répond 503 tant que tous les bundles activés ne sont pas prêts. Avec Laya et Von par défaut, ça fait environ 5.3 GB et quelques minutes la première fois, et CLM rajoute ses 75 MB par-dessus. 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 les bodies natifs de Laya et de Von: 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 = engine

Oui, 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 quatre outils: system_one, system_one_batch, 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, les questions et le config optionnel 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. system_one_batch, c’est la version MCP de POST /v1/systemone/batch: une liste de requêtes indépendantes, chacune avec son modèle, son état et ses questions, avec les réponses dans l’ordre où tu les as envoyées et chaque résultat qui nomme le modèle qui a vraiment répondu. Les requêtes pour un même modèle passent l’une après l’autre, DECIDEALOT_MAX_BATCH_CONCURRENCY plafonne le nombre d’appels aux providers qui se chevauchent, et un seul élément en échec fait échouer tout le batch, sans résultats partiels. 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 arbitraire. Le seul réglage qu’une requête a droit de passer, c’est config, et pour l’instant seul CLM y accepte quelque chose: une temperature validée. Tous les autres répondent par un 422 dès qu’on y met quelque chose. Le routeur de modèles associe chaque alias local à un endpoint loopback fixe et chaque nom Jev à l’unique API HTTPS fixe de TypeSafe, 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. L’URL d’embeddings de CLM et la clé TypeSafe, c’est celui qui déploie la machine qui les fixe, une fois pour toutes, et aucune requête n’y touche.

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 tmpfs noexec pour /tmp et /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 --user que tu lui passes, quel qu’il soit, donc le répertoire de modèles que tu viens de créer avec mkdir est accessible en écriture sans le rituel du chown. L’image ne retombe sur un 1000:1000 non-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 exec sur /var/cache. Pour tout le reste, lecture seule et noexec.
  • Une auth bearer qui ne fuit pas par le timing. Optionnelle, désactivée tant que DECIDEALOT_API_KEY n’est pas définie, et la comparaison passe par hmac.compare_digest sur des octets encodés. /health reste 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 un 413 avant qu’un modèle ne le voie.
  • Des request IDs que tu peux grepper. Envoie un UUID ou un ULID dans X-Request-Id et 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-newer de uv, et la stack des modèles s’installe dans le venv Torchbase avec --require-hashes à partir d’un fichier verrouillé 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, chacune posée sur l’image Torchbase qui va avec. Celle pour CPU fait à peu près 300 MB compressée sur Docker Hub, et c’est le bon choix par défaut. Celle pour CUDA fait environ 5.4 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/health

Attends 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. Là-dedans, CLM est activé par défaut: make run-bg lance aussi la route llama.cpp CUDA d’aigate, local-llamacpp-cuda-qwen3-8b, qui sert des embeddings Qwen3 8B avec exactement les 4096 dimensions que CLM attend, via le réseau interne. Du coup, même l’état que CLM envoie ne quitte jamais ta stack. Cette route exige un GPU NVIDIA même à côté du service CPU, donc sur une machine qui n’en a pas, mets DECIDEALOT_CLM_ENABLED=false. Le Jev hébergé n’apparaît que si tu mets DECIDEALOT_TYPESAFE_API_KEY dans le .env d’aigate, et là, l’état sort bel et bien de ta stack. Un seul hic: aigate épingle decidealot en v0.6.0, donc le réglage temperature de CLM arrivé en v0.7.0 n’y est pas encore.

aigate fait aussi la police dans la mémoire à ta place. Avant que n’importe quel autre modèle local routé par le LiteLLM d’aigate (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 Laya et Von prennent le même verrou matériel que tout le reste, qu’elles arrivent par REST, par MCP ou dans un batch, et sur CUDA aigate décharge son propre encodeur Qwen avant que Laya ou Von se charge. CLM laisse le verrou à son appel d’embeddings imbriqué. 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 trou qui reste: une requête envoyée directement à /decidealot/ ne vire que cet encodeur, pas tous les autres modèles qui squattent le GPU, donc un modèle résident qui n’a rien à voir peut quand même te bouffer la VRAM.

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, Von, CLM ou le Jev hébergé, 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@psyb0t

Codex 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/decidealot

decidealot 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, CLM, 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.