Principe directeur
Le nœud tire, il n’est jamais poussé. Un nœud qui meurt (batterie, doze, HyperOS, réseau coupé) est un non-événement : il cesse simplement de réclamer du travail. Aucun chemin de code ne suppose qu’un nœud est joignable.
Le coordinateur décide. Le nœud annonce son état ; il ne choisit pas ses jobs. C’est le coordinateur qui sait qu’un téléphone à 12 % de batterie et à 47 °C ne doit pas être réquisitionné.
Transport
NATS 2.x avec JetStream, sur le VPS.
| Usage | Accès |
|---|---|
| Nœuds (téléphone, laptop, PC) | wss://nats-cluster.vpsdashboard.space |
| Coordinateur (même réseau Docker) | nats://nats-cluster:4222 |
| Monitoring | http://nats-cluster:8222 |
plus large que l’écran — faites-le glisser
Le nœud ouvre une connexion sortante. Aucun port entrant, aucune IP publique, aucun VPN : le CGNAT de l’opérateur mobile n’est pas un sujet.
Authentification : un token par nœud, révocable individuellement.
Identité d’un nœud
nodeId — UUID v4 généré au premier lancement de l’app, persisté. Il survit aux mises à jour ; il change seulement si l’utilisateur réinitialise le nœud.
Sujets NATS
| Sujet | Sens | Qui publie |
|---|---|---|
cluster.v1.register | request/reply | nœud → coordinateur |
cluster.v1.telemetry.{nodeId} | publication | nœud |
cluster.v1.pop | request/reply | nœud → coordinateur |
cluster.v1.progress.{jobId} | publication | nœud |
cluster.v1.result.{jobId} | publication | nœud |
cluster.v1.control.{nodeId} | publication | coordinateur → nœud |
cluster.v1.logs.{nodeId} | publication | nœud |
cluster.v1.state.{nodeId} | publication | nœud |
plus large que l’écran — faites-le glisser
§1Enregistrement — cluster.v1.register
À chaque connexion (et après toute reconnexion), le nœud annonce ce qu’il sait faire. Idempotent : ré-enregistrer écrase la fiche précédente.
{ "protocol": 1, "nodeId": "9f2c...", "name": "Redmi Ichai", "platform": "android", "os": "Android 14 / HyperOS 1.0.7.0.UOOMIXM", "soc": "mt6878", "cpuCores": 8, "ramTotalMb": 12288, "appVersion": "0.1.0", "backends": ["cpu", "vulkan"], "models": [ { "id": "qwen2.5-3b-instruct-q4_k_m", "sizeMb": 1930, "ctxMax": 8192 } ], "maxCtx": 8192 }
Réponse :
{ "ok": true, "protocol": 1, "serverTime": 1756329600000, "popIntervalMs": 3000 }
Un protocol non reconnu entraîne { "ok": false, "error": "protocol_unsupported" }. Le nœud doit alors se mettre en pause et le signaler dans son interface.
§2Télémétrie — cluster.v1.telemetry.{nodeId}
Publiée toutes les 10 s tant que le service tourne. C’est le battement de cœur : c’est elle, et rien d’autre, qui prouve qu’un nœud est vivant.
{ "protocol": 1, "nodeId": "9f2c...", "ts": 1756329600000, "state": "idle", "batteryPct": 87, "charging": true, "thermalStatus": 1, "socTempC": 38.4, "cpuLoad": 0.12, "ramFreeMb": 6100, "network": "wifi", "screenOn": false, "loadedModel": "qwen2.5-3b-instruct-q4_k_m", "currentJobId": null, "tokensPerSec": 8.4 }
state ∈ idle | busy | paused | loading | error.
thermalStatus reprend PowerManager.THERMAL_STATUS_* d’Android (0 = aucun, jusqu’à 6 = shutdown). network ∈ wifi | ethernet | cellular | none.
batteryPct, charging et thermalStatus sont facultatifs depuis le §19 : un appareil qui ne les connaît pas les omet au lieu de les inventer. Le §19 ajoute aussi hasBattery, powerSource et les champs GPU.
Un nœud sans télémétrie depuis 45 s est marqué stale et cesse d’être éligible. Ses jobs en vol sont remis en file (voir §6).
§3Réclamation de travail — cluster.v1.pop
Le nœud demande du travail, en joignant son état courant. Il n’y a pas de file par nœud : c’est le coordinateur qui décide, à cet instant précis, si ce nœud mérite un job.
Requête :
{ "protocol": 1, "nodeId": "9f2c...", "telemetry": { ... }, "canAccept": ["qwen2.5-3b-instruct-q4_k_m"] }
Réponse quand il n’y a rien (ou que le nœud est jugé inéligible) :
{ "job": null, "retryAfterMs": 3000, "reason": "no_work" }
reason ∈ no_work | battery_low | thermal | paused | network_policy | model_unavailable. Le nœud affiche cette raison dans son interface : c’est ce qui rend le système compréhensible au lieu d’opaque.
Réponse avec travail :
{ "job": { "jobId": "01J8...", "model": "qwen2.5-3b-instruct-q4_k_m", "messages": [{ "role": "user", "content": "..." }], "params": { "temperature": 0.7, "maxTokens": 512, "topP": 0.95, "stop": [] }, "stream": true, "deadlineMs": 120000 } }
Le nœud a deadlineMs pour terminer. Au-delà, le coordinateur considère le job perdu et le remet en file — le nœud doit abandonner sa génération s’il découvre qu’il a dépassé la deadline, pour ne pas produire un résultat en double.
Cadence de pop : popIntervalMs (défaut 3000 ms) en idle. Le nœud n’appelle jamais pop pendant qu’il est busy.
§4Progression — cluster.v1.progress.{jobId}
Publiée pendant la génération quand stream est vrai. Un message par lot de tokens (le nœud regroupe, il ne publie pas un message par token).
{ "protocol": 1, "jobId": "01J8...", "nodeId": "9f2c...", "seq": 12, "delta": "le texte généré", "done": false }
seq est strictement croissant à partir de 0. Le coordinateur réordonne sur seq et ignore tout doublon.
§5Résultat — cluster.v1.result.{jobId}
Publié une fois, à la fin.
{ "protocol": 1, "jobId": "01J8...", "nodeId": "9f2c...", "ok": true, "content": "réponse complète", "usage": { "promptTokens": 42, "completionTokens": 310 }, "timings": { "loadMs": 0, "promptMs": 890, "genMs": 36400, "tokensPerSec": 8.5 }, "finishReason": "stop" }
finishReason ∈ stop | length | abort | error. En cas d’échec : { "ok": false, "error": "oom" | "load_failed" | "aborted" | "..." }.
§6Reprise après mort d’un nœud
C’est le cœur du système, et la raison pour laquelle NATS/JetStream est là.
- Un job popé passe en vol avec une deadline.
- Le coordinateur balaie les jobs en vol toutes les 5 s.
- Un job dont la deadline est dépassée, ou dont le nœud est
stale, retourne en file,attempts += 1. - Au-delà de 3 tentatives, le job part en échec définitif et l’appelant reçoit une erreur — jamais une attente infinie silencieuse.
- Un job remis en file peut repartir sur un autre nœud. Les tokens déjà streamés sont abandonnés : le client reçoit la génération du nœud qui va au bout.
§7Contrôle — cluster.v1.control.{nodeId}
Le seul canal descendant. Le nœud y est abonné tant qu’il est connecté ; s’il est mort, le message est perdu — et c’est acceptable, aucun ordre n’est critique.
{ "protocol": 1, "action": "pause" | "resume" | "abort" | "reload_model" | "ping" | "revoked", "jobId": "01J8...", "model": "..." }
Un nœud ignore une action qu’il ne connaît pas. revoked est décrit au §19.3.
§8Politique d’éligibilité
Évaluée par le coordinateur à chaque pop, sur la télémétrie jointe. Valeurs par défaut, surchargeables par nœud :
| Règle | Seuil par défaut | reason renvoyé |
|---|---|---|
| Batterie sous seuil et non branché (sauté sans batterie, §19.1) | < 40 % | battery_low |
| Statut thermique Android (sauté s’il est absent) | >= 3 (severe) | thermal |
| Température SoC | > 45 °C | thermal |
Température GPU, si gpuTempC est présent (§19.2) | > 83 °C | thermal |
Réseau mobile alors que WiFi exigé (ethernet vaut le WiFi) | — | network_policy |
| Nœud en pause manuelle | — | paused |
| Modèle demandé non chargé ni disponible | — | model_unavailable |
plus large que l’écran — faites-le glisser
Ces seuils vivent côté coordinateur et sont réglables dans l’app : le nœud a le dernier mot sur lui-même. Un nœud qui se juge inapte ne poppe pas, quoi que pense le coordinateur.
§9API publique du coordinateur
POST /v1/chat/completions, compatible OpenAI, streaming SSE inclus.
Le champ model accepte un identifiant de modèle. Si aucun nœud ne le sert, la requête est mise en file d’attente durable — conformément à la politique arrêtée, il n’y a pas de repli vers une API cloud. Le client peut passer x-cluster-timeout: <ms> pour borner son attente ; sans en-tête, il attend.
GET /v1/models liste l’union des modèles annoncés par les nœuds vivants.
§10Journal distant — cluster.v1.logs.{nodeId}
Ajouté le 27/08/2026. Un nœud vit dans une poche : on ne peut pas aller lire son journal. Il le pousse donc lui-même, en continu, et le coordinateur le conserve. C’est ce qui rend un nœud diagnosticable à distance sans y toucher.
Publié par lots — jamais un message par ligne. Un lot part toutes les 5 s s’il y a quelque chose à dire, ou dès que 50 lignes sont en attente.
{ "protocol": 1, "nodeId": "9f2c...", "lines": [ { "ts": 1756329600000, "level": "info", "tag": "nats", "msg": "connecté à wss://..." }, { "ts": 1756329601000, "level": "error", "tag": "llama", "msg": "échec de chargement: ..." } ] }
level ∈ debug | info | warn | error.
Côté nœud : tampon circulaire en mémoire de 1000 lignes. Si la connexion est coupée, les lignes s’accumulent et partent à la reconnexion — un nœud qui a eu un problème réseau doit pouvoir raconter ce qui s’est passé pendant sa coupure. Au-delà du tampon, les plus anciennes sont perdues : le journal est un outil de diagnostic, pas une archive.
Côté coordinateur : tampon circulaire de 2000 lignes par nœud, en mémoire.
Exposition : GET /api/nodes/{nodeId}/logs?limit=200&level=warn et GET /api/logs pour le flux fusionné de tous les nœuds, le plus récent en tête.
Aucune donnée de conversation dans le journal. Ni prompt, ni réponse générée, jamais — seulement des événements techniques. Le journal traverse le réseau et se lit côté serveur : il ne doit pas devenir une fuite du contenu des requêtes.
§11État complet du nœud — cluster.v1.state.{nodeId}
Ajouté le 27/08/2026. La télémétrie (§2) est un battement de cœur : petite, fréquente, réduite à ce dont le scheduler a besoin pour décider. Cet objet-ci est l’inverse : l’inventaire complet de ce que le nœud est en train de vivre, pour qu’on puisse tout voir à distance sans jamais toucher l’appareil.
Publié toutes les 60 s, et immédiatement à chaque changement notable : modèle chargé ou déchargé, début ou fin de téléchargement, réglage modifié, job commencé ou terminé, permission accordée, reconnexion NATS, passage en pause.
{ "protocol": 1, "nodeId": "9f2c...", "ts": 1756329600000, "identity": { "name": "Redmi Ichai", "appVersion": "0.3.0", "versionCode": 3, "installedAt": 1756300000000 }, "device": { "manufacturer": "Xiaomi", "model": "24090RA29G", "soc": "mt6878", "abi": "arm64-v8a", "android": "14", "sdkInt": 34, "rom": "HyperOS 1.0.7.0.UOOMIXM", "cpuCores": 8, "ramTotalMb": 12288, "storageFreeMb": 41200 }, "runtime": { "serviceUptimeS": 4210, "serviceStartCount": 3, "connectedSince": 1756325400000, "natsReconnects": 2, "lastCrash": { "ts": 1756200000000, "message": "...", "stack": "..." } }, "models": { "installed": [ { "id": "qwen2.5-3b-instruct-q4_k_m", "fileName": "...gguf", "sizeBytes": 1929903264, "downloadedAt": 1756310000000, "sizeVerified": true } ], "loaded": { "id": "qwen2.5-3b-instruct-q4_k_m", "backend": "cpu", "ctxSize": 4096, "loadMs": 2400, "ramUsedMb": 2100 }, "downloading": { "id": "llama-3.2-3b-instruct-q4_k_m", "receivedBytes": 812000000, "totalBytes": 2019377696, "bytesPerSec": 1900000 } }, "settings": { "natsHost": "nats-cluster.vpsdashboard.space", "tokenSet": true, "minBatteryPct": 40, "wifiOnly": true, "allowWhenScreenOn": false, "paused": false, "logsEnabled": true, "contextSize": 4096 }, "permissions": { "notifications": true, "installPackages": true, "ignoringBatteryOptimizations": true, "bootCompleted": true }, "power": { "batteryPct": 87, "charging": true, "plugged": "ac", "thermalStatus": 1, "socTempC": 38.4, "screenOn": false, "network": "wifi", "deviceIdleMode": false }, "jobs": { "completed": 42, "failed": 1, "requeued": 0, "current": { "jobId": "01J8...", "startedAt": 1756329580000, "tokensSoFar": 120 }, "last": { "jobId": "01J8...", "model": "qwen2.5-3b-instruct-q4_k_m", "promptTokens": 42, "completionTokens": 310, "tokensPerSec": 8.5, "totalMs": 37290, "finishReason": "stop" } }, "lastReject": { "ts": 1756329500000, "reason": "thermal", "detail": "SoC 47 °C > 45 °C" } }
Jamais de contenu de conversation ici non plus — ni prompt, ni réponse, ni extrait. Des compteurs, des identifiants, des mesures. Le champ tokenSet est un booléen : le token lui-même ne quitte jamais l’appareil.
settings.natsHost est l’hôte seul, sans le token.
Côté coordinateur : conserver le dernier état de chaque nœud plus un historique des 200 derniers jobs par nœud (identifiants, timings, débits — pas de contenu). Exposition :
GET /api/nodes/{nodeId}/state— l’inventaire complet ci-dessusGET /api/nodes/{nodeId}/jobs?limit=50— l’historique des jobsGET /api/overview— une vue unique rassemblant, pour tous les nœuds : état, modèle chargé, éligibilité et sa raison, dernier job, erreurs récentes, profondeur de file. C’est la page à lire quand on veut tout savoir d’un coup.
§12Calibration — mesurer au lieu de croire les fiches techniques
Ajouté le 28/08/2026. Les specs mentent : le Redmi Note 14 Pro annonce 12 Go de RAM, ce qui laisse croire qu’un 8B passe, alors que sa bande passante de 51,2 Go/s le plafonne — mesuré à 4 à 5 tok/s sur un 3B. On ne déduit donc plus le modèle des specs : on mesure.
Au premier lancement, puis après tout changement matériel notable (nouvelle ROM, modèle différent), le nœud exécute une génération de calibration courte (~64 tokens, prompt fixe) et publie le résultat dans register :
"capability": { "measuredAt": 1756329600000, "model": "qwen2.5-3b-instruct-q4_k_m", "tokPerSec": 4.6, "promptTokPerSec": 8.1, "loadMs": 1460, "backend": "cpu", "tier": "leger" }
tier ∈ leger (< 8 tok/s) · moyen (8–25) · rapide (25–80) · gros (> 80). Le palier est déduit du débit MESURÉ, jamais de la RAM.
§13Identité, propriété et cloisonnement
Ajouté le 28/08/2026. Le cluster devient multi-utilisateur.
Deux familles de clés, révocables séparément. Une clé de nœud autorise un appareil à se connecter à NATS. Une clé d’API autorise une personne à envoyer des requêtes. Elles ne se mélangent jamais : perdre un téléphone ne compromet pas les requêtes, et retirer l’accès à quelqu’un ne coupe pas les appareils.
Codes d’invitation. Un code à usage unique généré par l’administrateur s’échange sur le site contre une clé d’API. Aucun compte, aucun mot de passe.
Propriétaire. Chaque nœud déclare un ownerId dans register. Chaque clé d’API porte le même identifiant pour son détenteur.
Cloisonnement d’une requête. Le champ visibility d’un job vaut public (défaut) ou private.
public: le job va sur n’importe quel nœud éligible. C’est le cercle de confiance assumé — le nœud qui génère voit le texte, et c’est dit clairement.private: le job n’est servi que par les nœuds dont l’ownerIdest celui de l’appelant. S’il n’en a aucun de disponible, le job attend — il ne bascule JAMAIS sur l’appareil de quelqu’un d’autre. Un refus de pop pour cette raison renvoiereason: "private_no_node".
Côté API OpenAI : en-tête x-cluster-private: 1, ou "visibility": "private" dans le corps.
§14Correctif de télémétrie — température
Ajouté le 28/08/2026. socTempC est inexploitable : Android interdit à une app ordinaire de lire /sys/class/thermal, le champ remonte vide. cpuLoad est mort pour la même raison (/proc/stat est bloqué).
La télémétrie (§2) porte donc désormais batteryTempC, lu via BatteryManager.EXTRA_TEMPERATURE — accessible sans privilège, au dixième de degré. Ce n’est pas le SoC, mais sur un téléphone les deux sont couplés, et c’est un signal continu là où thermalStatus n’est qu’un palier grossier qui ne bouge qu’une fois le bridage déjà commencé.
Seuil d’éligibilité : batteryTempC > 42 °C ⇒ thermal. socTempC et cpuLoad restent facultatifs et ne sont plus utilisés pour décider.
§15Profilage d’appareil — prédire au lieu d’essayer
Ajouté le 28/08/2026, remplace la calibration naïve du §12.
Le problème. On ne peut pas télécharger cinq modèles de 2 Go chacun pour découvrir lequel convient. Il faut mesurer l’APPAREIL une fois, puis PRÉDIRE le débit de chaque modèle du catalogue sans en télécharger aucun.
Le principe. La génération de tokens est bornée par la bande passante mémoire, pas par le processeur : produire un token oblige à relire tout le modèle. Le traitement du prompt, lui, est borné par le calcul. Ces deux grandeurs se mesurent sans aucun modèle, en quelques secondes.
§15.1Le profil — six mesures, aucun modèle requis
| Mesure | Méthode | Ce qu’elle prédit |
|---|---|---|
memBandwidthGBs | lecture en flux d’un tampon nettement plus grand que le cache de dernier niveau, en montant le nombre de fils jusqu’au plateau | le débit de génération |
computeGFLOPS | produit matriciel dense dont les opérandes tiennent en cache | la vitesse de traitement du prompt |
usableMemMB | plus grande allocation réellement obtenue, par doublements puis dichotomie, immédiatement libérée | quels modèles tiennent vraiment |
sustainRatio | la même charge tenue 90 s, débit échantillonné toutes les 5 s : rapport fin/début, et instant du décrochage | ce qu’il reste après l’échauffement |
storageReadMBs + storageFreeMB | lecture séquentielle d’un fichier temporaire | le temps de chargement, et si le fichier tient |
backends[] | pour CHAQUE backend présent (cpu, vulkan, opencl, metal, cuda, rocm), le même produit matriciel, et le gain RÉEL mesuré | lequel utiliser — jamais déduit de sa présence |
plus large que l’écran — faites-le glisser
Durée totale visée : moins de 30 secondes, sans réseau, sans modèle. Le profil est daté et refait si le matériel ou le système change.
§15.2L’étalonnage — une seule génération réelle
Les mesures brutes surestiment : un moteur d’inférence n’atteint jamais la bande passante théorique. Une seule génération réelle, sur le premier modèle installé, donne les deux rendements :
etaDecode = tokPerSecMesure / (memBandwidthGBs * 1e9 / tailleModeleOctets) etaPrefill = promptTokPerSec / (computeGFLOPS * 1e9 / (2 * nbParametres))
Typiquement etaDecode tombe entre 0,15 et 0,45. Il est propre au couple appareil + moteur, et il se réutilise pour tous les autres modèles.
§15.3La prédiction — pour chaque modèle du catalogue
decodePrevu_tokS = etaDecode * memBandwidthGBs * 1e9 / tailleModeleOctets prefillPrevu_tokS = etaPrefill * computeGFLOPS * 1e9 / (2 * nbParametres) decodeSoutenu = decodePrevu * sustainRatio memoireRequise = tailleModele + cacheKV(ctx) + surcout moteur
Le cache KV n’est pas négligeable : il croît avec la taille de contexte et peut dépasser le modèle lui-même sur un contexte long. Il entre dans le calcul.
§15.4Le choix — une fonction d’aptitude, pas un seuil
Un modèle est réalisable si memoireRequise <= usableMemMB * 0,8 — la marge de 20 % existe parce que le système peut reprendre de la mémoire à tout moment, et qu’un nœud tué en pleine génération est pire qu’un nœud lent.
Parmi les réalisables, on retient le plus grand dont decodeSoutenu reste au-dessus du plancher d’usage :
| Usage | Plancher | Pourquoi |
|---|---|---|
| Interactif | 6 tok/s | en dessous, l’attente devient pénible |
| Assistant | 3 tok/s | acceptable si la réponse arrive |
| Traitement par lots | 1 tok/s | la nuit, personne ne regarde |
plus large que l’écran — faites-le glisser
Le plus grand modèle qui tient, pas le plus rapide : à débit suffisant, la qualité prime. C’est l’inverse de la règle naïve actuelle.
§15.5Portabilité
Ces six mesures ne dépendent d’aucune plateforme : ce sont de la mémoire, du calcul, du disque et de la chaleur. La même spécification vaut pour Android aujourd’hui, et demain pour un portable, une tour avec GPU, un iPhone ou un Raspberry Pi. L’implémentation doit donc isoler la mesure du système : un noyau natif portable, et une fine couche par plateforme pour l’énumération des backends et la lecture de la mémoire disponible.
Le profil est publié dans register sous deviceProfile, à côté de capability (§12) qui reste le résultat étalonné.
§16Enrôlement sans friction — un code court, ou rien du tout
Ajouté le 28/08/2026. Aujourd’hui, rejoindre le parc demande de coller à la main une clé de nœud de 48 caractères hexadécimaux, une clé d’API, et de vérifier deux URL. Sur un téléphone, chacune de ces étapes perd des gens. Le but de cette section est qu’il n’en reste aucune.
§16.1Le code d’enrôlement — court, lisible, jetable
Un code de la forme FLK-4H2K : préfixe fixe, puis 4 à 6 caractères d’un alphabet sans ambiguïté visuelle (ni 0/O, ni 1/I/l). Il se dicte au téléphone, se lit sur un écran, se tape sans erreur.
POST /api/enroll { code } — sans authentification, mais strictement limité en débit et à usage unique. Il renvoie TOUT ce dont l’appareil a besoin :
{ "nodeKey": "...", "apiKey": "...", "ownerId": "own_...", "natsUrl": "wss://nats.floky.dev:443", "coordinatorUrl": "https://api.floky.dev", "nodeName": "Téléphone de Malka" }
L’appareil n’a donc plus jamais rien à saisir : ni URL, ni clé, ni propriétaire. Le code expire (24 h par défaut) et meurt à la première utilisation.
§16.2Le lien profond — zéro saisie
https://floky.dev/rejoindre/FLK-4H2K
- App installée : le lien l’ouvre directement, elle lit le code et s’enrôle seule.
- App absente : la page propose le téléchargement et retient le code, pour que le premier lancement le retrouve.
C’est le chemin normal : on envoie un lien par message, la personne appuie, c’est fini.
§16.3Le QR — quand on est dans la même pièce
L’app affiche un QR contenant ce même lien. L’autre le scanne à l’installation. Aucun réseau tiers, aucun message à envoyer.
§16.4Ce que ça impose à l’interface
- Les champs « clé du nœud », « clé d’API », « URL NATS » et « URL du coordinateur » quittent le chemin principal et passent dans une section « Avancé », repliée. Ils restent modifiables — on ne retire pas le contrôle, on retire l’obligation.
- Le parcours de premier lancement propose d’abord « J’ai un code » et « Scanner un QR ». La saisie manuelle est un troisième choix, discret.
- Un code refusé dit pourquoi : expiré, déjà utilisé, inconnu — jamais un échec muet.
§16.5Sécurité
Le code est un secret de courte durée qui donne accès au parc : usage unique, expiration, limitation de débit par IP, et révocable depuis l’administration. Il ne remplace pas les clés — il sert à les livrer, une fois.
§16.6La connexion NATS par clé de nœud
Ajouté le 01/10/2026. La clé de nœud reçue au §16.1 est le jeton de connexion NATS : l’appareil la passe comme token à la connexion, en TCP comme en WebSocket. Le serveur NATS ne connaît plus aucun jeton lui-même : il demande au coordinateur, à chaque connexion, s’il l’accepte (« auth callout » de NATS 2.10).
- Clé de nœud valide : acceptée. La connexion ne peut publier que sur
cluster.v1.register,cluster.v1.pop,cluster.v1.{telemetry,progress,result,logs,state}.*et ne s’abonner qu’àcluster.v1.control.*et_INBOX.>. - Jeton partagé historique (
NATS_TOKEN) : toujours accepté, sans restriction — les appareils installés avant le §16 ne perdent rien. - Clé révoquée ou inconnue : refusée (
Authorization Violation). Une clé révoquée pendant qu’elle est connectée voit sa connexion coupée sur-le-champ, une demi-seconde après l’annoncerevokeddu §19.3. NATS ne transmet pas au client la raison d’un refus : c’estGET /api/node-key/status(§19.3) qui la donne. - Le propriétaire suit la connexion. Un
registervenu d’une connexion ouverte par clé prend le propriétaire de cette clé ; l’ownerIddéclaré est ignoré s’il le contredit. Seule une attribution d’administration (PUT /api/admin/nodes/{nodeId}/owner) passe avant. Le nœud continue de porter sa clé dansnodeKey(§17) : c’est elle qui sert si la connexion ne peut pas être rattachée.
Conséquence assumée : sans coordinateur, aucune nouvelle connexion n’aboutit. Un nœud n’a de toute façon rien à faire tant que le coordinateur est absent ; il réessaie comme après une coupure réseau.
§17Parc personnel — mes appareils ne travaillent que pour moi
Ajouté le 01/10/2026, décision d’Ichai : on crée son parc depuis le site, on y ajoute ses appareils, et toutes les ressources de ce parc sont à soi.
Un parc personnel par propriétaire. Un propriétaire connecté sur le site (compte Google → ownerId) crée son parc d’un geste. Le parc n’a qu’un attribut qui change le comportement : il existe. Il porte aussi un nom affiché et une date de création.
Ce que ça change à l’ordonnancement — deux règles, sans exception :
1. Un nœud dont le propriétaire a un parc personnel ne sert que les jobs de ce propriétaire, publics ou privés. Un pop qui ne trouve que des jobs d’autrui répond { "job": null, "reason": "no_work" } — pas une raison nouvelle : pour le nœud, il n’y a simplement rien à faire. 2. Un job envoyé par un propriétaire qui a un parc personnel est privé par défaut (§13) : il ne part que sur ses propres nœuds, et attend s’ils sont tous indisponibles. Il ne bascule jamais sur l’appareil de quelqu’un d’autre. "visibility": "public" explicite dans le corps reste possible et l’envoie au parc commun.
Ajouter un appareil. Depuis le site, le propriétaire génère un code d’enrôlement (§16) lié à son ownerId ; le lien /rejoindre/{code} et le QR en découlent. L’appareil qui l’échange reçoit une clé de nœud au nom de ce propriétaire : dès son premier register, il appartient au parc. Le propriétaire peut retirer un appareil de son parc depuis le site (sa clé de nœud est révoquée).
API du coordinateur (appelée par le serveur du site avec le jeton d’administration, au nom du propriétaire de la session) :
| Méthode | Adresse | Effet |
|---|---|---|
GET | /api/admin/owners/{ownerId}/parc | { parc: null } ou { parc: { name, createdAt, nodes: [...] } } |
POST | /api/admin/owners/{ownerId}/parc | crée le parc (idempotent), corps { name? } |
POST | /api/admin/owners/{ownerId}/enroll-codes | code d’enrôlement lié à ce propriétaire, corps { nodeName? } |
DELETE | /api/admin/owners/{ownerId}/nodes/{nodeId} | retire l’appareil : révoque sa clé de nœud |
DELETE | /api/admin/owners/{ownerId}/parc | supprime un parc vide (409 parc_not_empty s’il reste un appareil) et révoque ses codes d’enrôlement encore utilisables |
plus large que l’écran — faites-le glisser
Chaque élément de nodes : { nodeId, name, online, state, lastSeen, firstSeenAt, platform, appVersion, loadedModel, capabilities, currentJobs }.
Un appareil retiré reçoit à chaque pop { "job": null, "reason": "paused", "revoked": true } tant qu’il ne s’est pas réenrôlé (§19.3 dit ce qu’il doit en faire). La clé révoquée n’est connue du coordinateur que si le nœud l’a présentée : le nœud porte donc sa clé de nœud dans le champ nodeKey de register (§13).
§18Images et audio
Ajouté le 01/10/2026. On envoie une image, on reçoit sa description ; on envoie un audio, on reçoit sa transcription, et seulement elle (décision d’Ichai). Les deux par l’app ET par l’API, au même titre que le texte.
§18.1Ce que le nœud sait faire
register et chaque pop portent un champ nouveau, absent = [] :
{ "capabilities": ["vision", "transcription"] }
vision: le modèle chargé a son projecteur d’images (mmproj) présent sur l’appareil. Les modèles du catalogue qui en ont un le déclarent (§18.4).transcription: un modèle de transcription est présent sur l’appareil.
Le coordinateur ne donne un job qu’à un nœud qui annonce la capacité qu’il exige. Sinon pop répond reason: "model_unavailable".
§18.2Les médias ne passent pas par NATS
Un média est déposé chez le coordinateur, et le job n’en porte qu’une référence. Le nœud le télécharge en HTTPS au moment de travailler :
GET {coordinatorUrl}/v1/media/{mediaId} Authorization: Bearer <clé de nœud cnk_…> x-cluster-job: <jobId>
Un nœud relié par le jeton NATS partagé (antérieur au §16, sans clé cnk_) présente ce jeton à la place. Clé absente ou invalide : 401.
Le coordinateur ne le rend que si ce job est en vol sur ce nœud-là. Sinon 404, sans dire si le média existe. Le média est effacé dès que le job se termine (succès ou échec définitif), et au plus tard une heure après son dépôt. Un job encore en file quand son média expire part en échec définitif (media_expired).
Le coordinateur normalise ce qu’il reçoit avant de le déposer, pour que les nœuds n’aient qu’un format à lire :
| Média | Accepté à l’entrée | Ce que reçoit le nœud | Limite |
|---|---|---|---|
| Image | JPEG, PNG, WebP, HEIC, GIF (1re image) | JPEG, côté le plus long ≤ 1536 px | 20 Mo, 4 images par requête |
| Audio | WAV, MP3, M4A/AAC, OGG/Opus, WebM, FLAC | WAV PCM 16 bits, mono, 16 kHz | 25 Mo, 30 min |
plus large que l’écran — faites-le glisser
§18.3La forme des jobs
Un job porte désormais kind ∈ chat (défaut si absent) | transcription.
Chat avec images. Le content d’un message est soit une chaîne (inchangé), soit une liste de parties :
{ "role": "user", "content": [ { "type": "text", "text": "Que vois-tu ?" }, { "type": "image", "mediaId": "med_…", "mime": "image/jpeg", "bytes": 182311 } ] }
Transcription.
{ "jobId": "…", "kind": "transcription", "model": "whisper", "audio": { "mediaId": "med_…", "mime": "audio/wav", "bytes": 1920044, "durationMs": 60000 }, "params": { "language": "fr" }, "deadlineMs": 600000 }
Un job de transcription s’apparie sur la seule capacité transcription, pas sur canAccept ; son model vaut whisper (alias whisper-1 compris) ou l’identifiant d’une entrée de transcription du catalogue. Un job de conversation avec images exige le modèle dans canAccept et la capacité vision.
params.language est facultatif : absent, le nœud détecte la langue. Le résultat (§5) porte le texte dans content, et en plus language (code détecté ou imposé) et audioMs. Pas de progress pour une transcription.
La deadline d’une transcription se calcule sur la durée : 2 min + 4 × la durée de l’audio.
La deadline d’un job de conversation grandit avec ses images : 120 s + 180 s par image. Lire une image est un bloc que le nœud ne peut pas interrompre, et il a fallu 84 s à l’émulateur pour une image de 384 px : 180 s en laissent le double, de quoi couvrir une image plus grande ou un appareil qui chauffe, sans laisser un job perdu bloquer la file plus de quelques minutes.
§18.4Le catalogue
Une entrée de modèle de conversation peut porter "mmproj": { "url", "sizeBytes", "sha256" } : c’est ce qui la rend capable de lire des images. Les entrées de models portent "kind": "chat".
Les modèles de transcription sont des entrées à part, dans un tableau distinct transcriptionModels à la racine du catalogue — jamais dans models, où une app antérieure les proposerait comme modèles de conversation. Chacune porte "kind": "transcription", "format": "ggml", id, name, url, sizeBytes, sha256, minRamMb (whisper.cpp, fichiers ggml-*.bin).
§18.5L’API publique
POST /v1/chat/completionsaccepte les parties OpenAI{ "type": "image_url", "image_url": { "url": "data:image/…;base64,…" | "https://…" } }. Le coordinateur télécharge l’adressehttpslui-même (10 s, 20 Mo au plus). Si le modèle demandé n’a pas de projecteur d’images au catalogue :400 model_not_vision.POST /v1/audio/transcriptions, compatible OpenAI,multipart/form-data:file(obligatoire),model(whisper-1est accepté comme alias),language,response_format∈json(défaut,{ "text": "…" }) |text|verbose_json({ "text", "language", "duration" }). Même file d’attente durable que le reste, mêmes règles de propriété (§13, §17), mêmex-cluster-timeout.
§18.6Confidentialité
Les §10 et §11 s’appliquent aux médias comme au texte : aucune image, aucun audio, aucune transcription, aucun nom de fichier dans la télémétrie, l’état ou le journal. Le nœud efface le média de son stockage dès le job terminé. Dans l’app, en local, rien ne sort du téléphone : l’image et l’audio y sont lus sur place.
§19Ordinateurs, GPU et appareil retiré
Ajouté le 02/10/2026, à la demande de l’app Windows. Tout est facultatif et reste au protocole 1 : un nœud Android d’avant ne change rien et reste servi à l’identique.
§19.1Un appareil sans batterie
La télémétrie (§2) et l’état (§11, bloc power) acceptent :
| Champ | Valeurs | Sens | |
|---|---|---|---|
hasBattery | booléen | false : l’appareil n’a pas de batterie (PC fixe) | |
powerSource | ac \ | battery | ce qui l’alimente en ce moment |
batteryPct, charging | absents ou null | quand il n’y a pas de batterie, on les omet — on n’envoie plus 100 %, en charge | |
thermalStatus | absent ou null | propre à Android ; un ordinateur l’omet |
plus large que l’écran — faites-le glisser
Règle « batterie basse » (§8) : elle ne s’applique que si hasBattery n’est pas false, que batteryPct est présent, et que l’appareil n’est ni en charge (charging: true) ni sur secteur (powerSource: "ac"). Un portable sur secteur à 15 % reste donc éligible ; le même sur batterie ne l’est pas.
Ce qu’envoie un PC fixe :
{ "hasBattery": false, "powerSource": "ac", "network": "ethernet", "cpuLoad": 0.21 }
§19.2Ethernet et GPU
network gagne ethernet. La politique « WiFi exigé » (requireWifi) le traite comme le WiFi : ce qu’elle protège, c’est le forfait mobile, et un câble n’en consomme pas.
La télémétrie et le bloc power de l’état acceptent, tous facultatifs :
| Champ | Unité |
|---|---|
gpuLoad | fraction 0 à 1, comme cpuLoad (pas un pourcentage : 40 est refusé) |
gpuTempC | °C |
vramFreeMb, vramTotalMb | Mo |
plus large que l’écran — faites-le glisser
Ils sont conservés et rendus par /api/overview.
Seuil thermique GPU : gpuTempC > 83 °C ⇒ thermal, réglable par nœud (maxGpuTempC dans la politique). 83 °C est la température à partir de laquelle les cartes NVIDIA grand public commencent à baisser leurs fréquences (GPU Boost) : au-dessus, la carte bride déjà et ventile fort, l’opposé de « ne jamais sentir que l’appareil travaille ». Les seuils d’un téléphone (42 °C batterie, 45 °C SoC) n’ont pas de sens ici : un GPU sous charge tourne normalement entre 65 et 80 °C. Une carte AMD qui ne remonte que la température de jonction (souvent 95 à 110 °C en charge) doit envoyer sa température de bord (« edge »), pas la jonction, sinon elle sera toujours refusée.
§19.3Un appareil retiré de son parc
Retirer un appareil (§17) ou révoquer sa clé de nœud (DELETE /api/admin/keys/{keyId}) doit être su par l’appareil, et pas seulement subi : sans ça, la connexion est refusée en Authorization Violation, l’app réessaie sans fin et affiche toujours « relié ».
Trois signaux, du plus immédiat au plus sûr :
1. Annonce en direct. Juste avant de couper la connexion, le coordinateur publie sur cluster.v1.control.{nodeId} : ``json { "protocol": 1, "action": "revoked", "reason": "node_key_revoked" } `` Il n’arrive que si l’appareil est connecté à ce moment-là. 2. Dans les réponses. Un nœud encore relié (jeton partagé historique) qui présente une clé révoquée reçoit "revoked": true dans la réponse à register, et { "job": null, "reason": "paused", "revoked": true } à chaque pop. 3. La question directe — le seul qui marche si l’appareil était éteint au moment du retrait. `` GET {coordinatorUrl}/api/node-key/status Authorization: Bearer <clé de nœud cnk_…> ` Pas de clé d'API à fournir : la clé de nœud suffit. Réponse 200 : `json { "status": "revoked", "revoked": true, "revokedAt": 1759398000000 } ` status ∈ active | revoked | unknown (clé que le coordinateur ne connaît pas) | wrong_kind (une clé d'API présentée à la place) | shared (le jeton partagé historique, jamais révocable). Sans en-tête : 401`.
Ce que l’app doit faire (Android et Windows à l’identique) :
- Sur une connexion NATS refusée pour autorisation (
Authorization Violation, à la première connexion comme à une reconnexion), elle appelleGET /api/node-key/statusavant de réessayer. Une erreur réseau ou un coordinateur muet sur cette adresse ne prouvent rien : elle réessaie alors comme après une coupure ordinaire. - Sur
status: "revoked"— ou sur l’actionrevoked, ourevoked: truedansregisteroupop— elle arrête de réessayer, arrête le service de nœud, efface sa clé de nœud et revient à l’écran Rejoindre avec la phrase : « Cet appareil a été retiré de son parc. Pour qu’il travaille de nouveau, rejoignez un parc avec un nouveau code. » Elle garde sonnodeId, ses modèles et ses réglages : un nouvel enrôlement le remet en service sans rien retélécharger. - Sur
status: "unknown", elle arrête aussi de réessayer et revient à l’écran Rejoindre (« Ce parc ne reconnaît pas cet appareil. Rejoignez-le avec un nouveau code. »), mais sans effacer sa clé tant qu’un nouvel enrôlement ne l’a pas remplacée : une clé inconnue peut venir d’un coordinateur qui a perdu son état, pas d’un retrait voulu. activeoushared: rien à faire de plus, le refus venait d’ailleurs ; elle réessaie normalement.- La clé d’API reçue à l’enrôlement n’est pas touchée par le retrait : c’est une clé de personne (§13), révoquée séparément.
fin du protocole v1 · 19 articles · révision 5950f1a du 28 août 2026
Toute divergence entre ce texte, le coordinateur et l’app est un bug, pas une variante.