#!/usr/bin/env python3 # © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Le transport de la découverte, contre un VRAI serveur qui se conduit mal. `collect` ouvre de vraies sockets vers `llm_fake_server.FakeLLM`, lié à la boucle locale sur un port choisi par le système. Rien ne quitte la machine, et aucune de ces requêtes ne touche un serveur LLM réel. Un double de `requests` ne rendrait que ce qu'on aurait imaginé en l'écrivant, et tout ce qui casse une découverte vient du TRANSPORT : une page d'administration qui rend du HTML là où l'échelle attend du JSON, un défi 401, un 503 pendant le chargement d'un modèle, une connexion coupée en plein corps, un corps qui ne finit pas, un écouteur qui accepte et ne répond jamais. Ce que ce fichier défend, et qui coûte cher à qui le casse : le PLAFOND de lecture, qui garde un corps de huit mégaoctets hors de la mémoire du menu ; le DÉLAI, qui empêche un écouteur bloqué de figer le balayage ; et les deux interdits du balayage — GET seulement, jamais d'en-tête `Authorization` — sans lesquels une découverte pourrait charger un modèle ou dépenser un jeton. Les corps servis sont ceux de `FIXTURES`, que lisent aussi les tests purs de `test_assistant_fingerprint.py` : les deux moitiés du module se vérifient donc contre les mêmes octets. """ import socketserver import time import unittest from llm_fake_server import ( FIXTURES, HOST, Cut, FakeLLM, Huge, Silent, port_ferme, ) from script.todo.assistant.fingerprint import ( BODY_CAP, collect, identify, probe_plan, ) # Les familles que le transport doit reconnaître de bout en bout. « gpt4all » # demande son port et « openai » son nom d'hôte : ni l'un ni l'autre ne se # joue dans le transport. # `FakeLLM.__exit__` appelle `shutdown()`, qui attend que la boucle d'accueil # remarque la demande. Sans argument, `serve_forever()` la sonde toutes les # 0,5 s : chaque serveur jetable coûte alors une demi-seconde de sommeil, et ce # fichier en démarre une vingtaine. Raccourcir la période de sondage ne change # ni ce qui est servi ni ce qui est reçu — elle ne règle que la vitesse à # laquelle un serveur accepte de mourir, donc si ce fichier reste une boucle # rapide. _SERVE_FOREVER = socketserver.BaseServer.serve_forever def _serve_forever_reactif(self, poll_interval=0.01): return _SERVE_FOREVER(self, poll_interval) def setUpModule(): socketserver.BaseServer.serve_forever = _serve_forever_reactif def tearDownModule(): socketserver.BaseServer.serve_forever = _SERVE_FOREVER FAMILIES = ( "ollama", "localai", "localai_starting", "llamacpp", "koboldcpp", "jan", "lmstudio", "tabbyapi", "textgen_webui", "vllm", ) class Transport(unittest.TestCase): """Ce que `collect` rend quand le serveur en face n'est pas poli.""" def test_un_port_mort_est_absent_pas_une_exception(self): # Le port vient d'être rendu par le système : la connexion y est # refusée tout de suite, elle n'expire pas. bodies = collect(HOST, port_ferme(), budget=1.0) self.assertEqual(bodies, {}) def test_du_html_sur_props_n_est_pas_un_llamacpp(self): with FakeLLM("routeur") as serveur: bodies = collect(serveur.host, serveur.port, budget=2.0) # La page a bien répondu, et elle a répondu à `/props` : c'est le cas # où une lecture non enveloppée lèverait. self.assertEqual(bodies["/props"][0], 200) self.assertTrue(bodies["/props"][1].startswith(b"