L'entrée « Question IA » envoyait toute question à une seule API distante, sur un modèle figé et sans historique : aucun serveur local n'était atteignable, et rien ne disait où partait le texte. Deux règles gouvernent ce qui la remplace. Un port ne dit jamais qui répond — un seul en héberge jusqu'à trois, et une famille réémet l'API d'une autre en entier — donc l'identité se lit dans le CORPS, par une échelle ordonnée. Et une adresse ne devient jamais du texte d'invite : le détecteur du dépôt ne voit pas les noms d'hôte, d'où une poignée opaque à sa place. Vérifié : 284 tests, et contre un serveur du réseau — reconnaissance, capacités lues, conversation multi-tours, triage juste. --- EN --- The "AI question" entry sent every question to a single remote API, on a frozen model and with no history: no local server was reachable, and nothing said where the text was going. Two rules govern what replaces it. A port never says who answers — one hosts up to three products, and one family re-serves another's native API in full — so identity is read from the BODY, by an ordered ladder. And an address never becomes prompt text: the repository's detector does not see host names, hence an opaque handle in its place. Checked: 284 tests, and against a server on the network — recognition, capabilities read, a multi-turn conversation, a correct triage. Assisted-by: Claude Opus 5
286 lines
12 KiB
Python
286 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
"""L'échelle de reconnaissance, sans un seul octet de réseau.
|
|
|
|
Ce fichier ne sonde rien : il appelle `identify` sur les corps de référence de
|
|
`llm_fake_server.FIXTURES`, ceux-là mêmes que le faux serveur sert au
|
|
transport. Les deux moitiés lisent donc les MÊMES octets ; sans ce partage,
|
|
l'analyse serait vérifiée contre une idée du protocole et le transport contre
|
|
une autre, et l'écart ne se verrait qu'en production.
|
|
|
|
Ce qu'il défend est l'ORDRE des étages, qui casse en silence. LocalAI sert
|
|
l'API native d'Ollama en entier, jusqu'à la chaîne « Ollama is running » sur
|
|
« / » : glisser l'étage LocalAI sous l'étage Ollama nomme « ollama » toutes
|
|
les machines LocalAI, sans lever, sans rien afficher d'anormal, et la
|
|
conversation part ensuite vers un serveur mal caractérisé. Le test le plus
|
|
important du fichier construit un corps qui satisfait les DEUX signatures et
|
|
exige « localai ».
|
|
|
|
Le reste défend la règle « l'identité se lit dans le corps » : ni le port ni
|
|
le code de statut ne nomment un logiciel, et un corps HTML, vide ou coupé rend
|
|
une empreinte sans logiciel plutôt qu'une exception.
|
|
"""
|
|
|
|
import unittest
|
|
|
|
from llm_fake_server import FIXTURES
|
|
|
|
from script.todo.assistant.fingerprint import (
|
|
GPT4ALL_PORT,
|
|
OPENAI_HOST,
|
|
PORTS,
|
|
Fingerprint,
|
|
identify,
|
|
probe_plan,
|
|
)
|
|
|
|
# Ce que chaque famille de référence doit se voir nommer. « gpt4all » et
|
|
# « routeur » n'y sont pas : le premier ne s'atteint que par son port et le
|
|
# second ne doit rien nommer du tout, donc chacun a son propre test.
|
|
EXPECTED = {
|
|
"ollama": "ollama",
|
|
"localai": "localai",
|
|
"localai_starting": "localai",
|
|
"llamacpp": "llamacpp",
|
|
"vllm": "vllm",
|
|
"lmstudio": "lmstudio",
|
|
"koboldcpp": "koboldcpp",
|
|
"jan": "jan",
|
|
"open_webui": "open_webui",
|
|
"textgen_webui": "textgen_webui",
|
|
"tabbyapi": "tabbyapi",
|
|
}
|
|
|
|
|
|
class Echelle(unittest.TestCase):
|
|
"""L'ordre des étages, qui est la seule chose que le corps ne dit pas."""
|
|
|
|
def test_localai_est_ecarte_avant_qu_on_interroge_ollama(self):
|
|
double = {**FIXTURES["ollama"], **FIXTURES["localai"]}
|
|
# Le corps satisfait les deux signatures à la fois : c'est ce que
|
|
# rend une machine LocalAI, et rien dans l'API d'Ollama ne l'en
|
|
# distingue.
|
|
self.assertIn("/api/tags", double)
|
|
self.assertIn("/", double)
|
|
self.assertIn("/readyz", double)
|
|
self.assertEqual(identify(double, port=11434).software, "localai")
|
|
|
|
def test_readyz_est_ce_qui_separe_localai_d_ollama(self):
|
|
double = {**FIXTURES["ollama"], **FIXTURES["localai"]}
|
|
sans_readyz = {
|
|
path: answer
|
|
for path, answer in double.items()
|
|
if path != "/readyz"
|
|
}
|
|
# Un mandataire qui masque `/readyz` retire le SEUL point qui écarte
|
|
# LocalAI : l'échelle ne doit alors plus prétendre le reconnaître.
|
|
self.assertNotEqual(identify(sans_readyz).software, "localai")
|
|
|
|
def test_une_version_0_9_0_figee_est_localai_pas_ollama(self):
|
|
fp = identify(FIXTURES["localai"])
|
|
self.assertEqual(fp.software, "localai")
|
|
# Le littéral que LocalAI sert sur `/api/version` ne décrit pas
|
|
# LocalAI : il n'est jamais rendu comme sa version, et il ne peut pas
|
|
# non plus servir à trancher, ce numéro-là ayant existé comme version
|
|
# réelle d'Ollama.
|
|
self.assertNotEqual(fp.version, "0.9.0")
|
|
self.assertIn("version", fp.unknown)
|
|
vrai = identify(FIXTURES["ollama"])
|
|
self.assertEqual((vrai.software, vrai.version), ("ollama", "0.6.2"))
|
|
|
|
def test_le_port_ne_decide_jamais_de_l_identite(self):
|
|
self.assertTrue(PORTS, "la liste des ports à frapper est vide")
|
|
for port in PORTS:
|
|
with self.subTest(port=port):
|
|
fp = identify(FIXTURES["llamacpp"], port=port)
|
|
self.assertEqual(fp.software, "llamacpp")
|
|
# Le port d'Ollama sans aucune réponse ne nomme pas Ollama.
|
|
self.assertEqual(identify({}, port=11434).software, "")
|
|
|
|
def test_gpt4all_ne_s_atteint_que_par_elimination(self):
|
|
corps = FIXTURES["gpt4all"]
|
|
self.assertEqual(
|
|
identify(corps, port=GPT4ALL_PORT).software, "gpt4all"
|
|
)
|
|
# Le même corps ailleurs ne nomme rien : l'étage est une élimination,
|
|
# pas une signature.
|
|
self.assertEqual(identify(corps, port=8080).software, "")
|
|
# Et l'élimination ne passe jamais devant un accord réel.
|
|
self.assertEqual(
|
|
identify(FIXTURES["koboldcpp"], port=GPT4ALL_PORT).software,
|
|
"koboldcpp",
|
|
)
|
|
# Sur le bon port, une page qui n'est pas un serveur LLM ne devient
|
|
# pas un GPT4All par défaut.
|
|
self.assertEqual(
|
|
identify(FIXTURES["routeur"], port=GPT4ALL_PORT).software, ""
|
|
)
|
|
|
|
def test_ollama_survit_a_une_confirmation_absente_pas_a_un_dementi(self):
|
|
corps = FIXTURES["ollama"]
|
|
sans_version = {
|
|
path: answer
|
|
for path, answer in corps.items()
|
|
if path != "/api/version"
|
|
}
|
|
fp = identify(sans_version)
|
|
# Une confirmation qui manque laisse l'accord debout : l'inconnu ne
|
|
# retire rien, il se dit inconnu.
|
|
self.assertEqual(fp.software, "ollama")
|
|
self.assertIn("version", fp.unknown)
|
|
# Un démenti, lui, retire l'accord : du JSON étranger monté sous
|
|
# `/api/version` n'est pas un Ollama.
|
|
dementi = {
|
|
**corps,
|
|
"/api/version": (200, b'{"version":"pas un numero"}'),
|
|
}
|
|
self.assertEqual(identify(dementi).software, "")
|
|
|
|
def test_openai_se_nomme_par_son_hote_sans_aucun_scan(self):
|
|
fp = identify({}, host=OPENAI_HOST)
|
|
self.assertEqual(fp.software, "openai")
|
|
self.assertEqual(identify({}, host="localhost").software, "")
|
|
|
|
|
|
class Signatures(unittest.TestCase):
|
|
"""Le champ précis qui accorde chaque étage, et pas un champ voisin."""
|
|
|
|
def test_chaque_famille_de_reference_se_nomme(self):
|
|
self.assertTrue(EXPECTED, "la table des familles attendues est vide")
|
|
for famille, logiciel in EXPECTED.items():
|
|
with self.subTest(famille=famille):
|
|
self.assertIn(famille, FIXTURES)
|
|
fp = identify(FIXTURES[famille], port=8080)
|
|
self.assertEqual(fp.software, logiciel)
|
|
|
|
def test_koboldcpp_se_nomme_par_son_champ_result(self):
|
|
fp = identify(FIXTURES["koboldcpp"])
|
|
self.assertEqual((fp.software, fp.version), ("koboldcpp", "0.0"))
|
|
autre = {"/api/extra/version": (200, b'{"result":"autre chose"}')}
|
|
self.assertEqual(identify(autre).software, "")
|
|
|
|
def test_open_webui_se_nomme_par_deployment_id(self):
|
|
self.assertEqual(
|
|
identify(FIXTURES["open_webui"]).software, "open_webui"
|
|
)
|
|
# `name` et `version` se retrouvent chez d'autres interfaces ; seul
|
|
# `deployment_id` accorde l'étage.
|
|
sans = {"/api/config": (200, b'{"name":"autre","version":"0.0.0"}')}
|
|
self.assertEqual(identify(sans).software, "")
|
|
|
|
def test_llamacpp_se_nomme_par_build_info_avec_chat_template_caps(self):
|
|
fp = identify(FIXTURES["llamacpp"])
|
|
self.assertEqual(
|
|
(fp.software, fp.version), ("llamacpp", "b9999-0000000")
|
|
)
|
|
# Les deux clés sont exigées ensemble : `build_info` seul se recopie.
|
|
seul = {"/props": (200, b'{"build_info":"b1-0000000"}')}
|
|
self.assertEqual(identify(seul).software, "")
|
|
# Derrière un mandataire qui masque `/props`, `owned_by` reste.
|
|
proxy = {"/v1/models": FIXTURES["llamacpp"]["/v1/models"]}
|
|
self.assertEqual(identify(proxy).software, "llamacpp")
|
|
|
|
def test_vllm_repond_sur_version_pas_api_version(self):
|
|
self.assertEqual(identify(FIXTURES["vllm"]).software, "vllm")
|
|
# `/api/version` appartient à Ollama et à LocalAI : le même corps sur
|
|
# ce chemin ne nomme pas vLLM.
|
|
ailleurs = {"/api/version": (200, b'{"version":"0.0.0"}')}
|
|
self.assertEqual(identify(ailleurs).software, "")
|
|
|
|
def test_un_401_sur_le_chemin_model_singulier_est_un_accord(self):
|
|
fp = identify(FIXTURES["tabbyapi"])
|
|
self.assertEqual(fp.software, "tabbyapi")
|
|
# Le défi porte sur `/v1/model` au singulier ; le pluriel est servi
|
|
# par onze serveurs sur douze et ne nomme personne.
|
|
pluriel = {"/v1/models": (401, b'{"detail":"Invalid API key"}')}
|
|
self.assertEqual(identify(pluriel).software, "")
|
|
|
|
|
|
class CorpsHostiles(unittest.TestCase):
|
|
"""Ce qu'un serveur mal élevé rend, et qui doit rester un résultat."""
|
|
|
|
def test_une_page_de_routeur_sur_8080_ne_nomme_rien(self):
|
|
fp = identify(FIXTURES["routeur"], port=8080)
|
|
self.assertEqual(fp.software, "")
|
|
self.assertEqual(fp.models, ())
|
|
self.assertIn("software", fp.unknown)
|
|
|
|
def test_un_503_starting_est_vivant_pas_mort(self):
|
|
fp = identify(FIXTURES["localai_starting"], port=8080)
|
|
self.assertEqual(fp.software, "localai")
|
|
# Le statut ne nomme pourtant rien par lui-même : `/health` rend 200
|
|
# chez trois serveurs et 503 pendant un chargement, sans distinguer
|
|
# personne.
|
|
self.assertEqual(identify(FIXTURES["llamacpp_loading"]).software, "")
|
|
|
|
def test_un_corps_tronque_ne_leve_pas(self):
|
|
self.assertTrue(FIXTURES, "les corps de référence ont disparu")
|
|
for famille, corps in FIXTURES.items():
|
|
coupe = {
|
|
path: (status, body[:12])
|
|
for path, (status, body) in corps.items()
|
|
}
|
|
with self.subTest(famille=famille):
|
|
self.assertIsInstance(identify(coupe, port=8080), Fingerprint)
|
|
coupe = {"/props": (200, b'{"build_in')}
|
|
self.assertEqual(identify(coupe).software, "")
|
|
|
|
def test_un_serveur_inconnu_ne_nomme_rien(self):
|
|
anonyme = {
|
|
"/v1/models": (
|
|
200,
|
|
b'{"object":"list","data":[{"id":"un-modele"}]}',
|
|
)
|
|
}
|
|
fp = identify(anonyme, port=8000)
|
|
self.assertEqual(fp.software, "")
|
|
self.assertIn("software", fp.unknown)
|
|
# Le catalogue reste lisible : un point de terminaison anonyme mais
|
|
# compatible OpenAI est utilisable, il n'est simplement pas nommé.
|
|
self.assertEqual(fp.models, ("un-modele",))
|
|
self.assertNotIn("models", fp.unknown)
|
|
|
|
def test_une_table_vide_ne_leve_pas(self):
|
|
fp = identify({})
|
|
self.assertEqual(fp.software, "")
|
|
self.assertEqual(
|
|
fp.unknown, frozenset({"software", "version", "models"})
|
|
)
|
|
|
|
|
|
class Plan(unittest.TestCase):
|
|
"""Ce que la découverte s'autorise à émettre, avant toute socket."""
|
|
|
|
def test_le_plan_ne_contient_que_des_GET_sans_doublon(self):
|
|
plan = probe_plan()
|
|
self.assertTrue(plan, "le plan de sonde est vide")
|
|
self.assertEqual({methode for methode, _ in plan}, {"GET"})
|
|
chemins = [chemin for _, chemin in plan]
|
|
self.assertEqual(len(chemins), len(set(chemins)))
|
|
|
|
def test_readyz_precede_tout_ce_qui_ressemble_a_ollama(self):
|
|
chemins = [chemin for _, chemin in probe_plan()]
|
|
self.assertEqual(chemins[0], "/readyz")
|
|
for tardif in ("/api/tags", "/", "/api/version"):
|
|
self.assertLess(chemins.index("/readyz"), chemins.index(tardif))
|
|
|
|
def test_aucun_chemin_du_plan_ne_declenche_une_generation(self):
|
|
chemins = [chemin for _, chemin in probe_plan()]
|
|
# `/api/show` est un POST : il appartient à l'interrogation des
|
|
# capacités, après le choix d'un serveur. Un balayage qui l'émettrait
|
|
# pourrait charger un modèle.
|
|
self.assertNotIn("/api/show", chemins)
|
|
for interdit in ("/completion", "/v1/chat/completions", "/api/chat"):
|
|
self.assertNotIn(interdit, chemins)
|
|
|
|
def test_les_ports_a_frapper_sont_uniques_et_portent_celui_de_gpt4all(
|
|
self,
|
|
):
|
|
self.assertEqual(len(PORTS), len(set(PORTS)))
|
|
self.assertEqual(len(PORTS), 11)
|
|
self.assertIn(GPT4ALL_PORT, PORTS)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|