[ADD] assistant : serveur LLM reconnu, catalogue gpt, sessions locales
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
2026-09-09 06:41:56 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
"""Le registre des serveurs LLM : la poignée sort, l'adresse reste.
|
|
|
|
|
|
|
|
|
|
Ce que ces tests défendent tient en deux phrases, et les deux sont des
|
|
|
|
|
règles du dépôt plutôt que des préférences.
|
|
|
|
|
|
|
|
|
|
Un nom d'hôte, un alias SSH, un nom de VM désignent une machine, et le
|
|
|
|
|
détecteur du dépôt ne les voit pas : il reconnaît les adresses, les
|
|
|
|
|
courriels et les chemins de compte, et rend une liste vide devant un nom.
|
|
|
|
|
Seule la STRUCTURE protège les noms, donc seul `redacted` a le droit de
|
|
|
|
|
sortir vers une invite. Une régression ici ne casse aucun écran et ne lève
|
|
|
|
|
rien : elle recopie une adresse dans un prompt, et rien ne le dira.
|
|
|
|
|
|
|
|
|
|
L'écriture ne va que dans le fichier gitignored, par `set_config_value`.
|
|
|
|
|
Les deux autres fichiers de configuration suivent le dépôt en amont : un
|
|
|
|
|
chemin de clés déplacé, ou un second écrivain ajouté, ferait remonter une
|
|
|
|
|
adresse de machine avec le prochain commit. Là encore, tout resterait vert.
|
|
|
|
|
|
|
|
|
|
Aucun fichier réel n'est touché : les accesseurs de configuration sont
|
|
|
|
|
injectés, et le seul cas qui teste le défaut remplace la classe elle-même.
|
|
|
|
|
|
|
|
|
|
Les hôtes et les libellés sont INVENTÉS. `.claude/rules/04-code-conventions.md`
|
|
|
|
|
l'exige pour la valeur qui illustre un interdit, et demande de vérifier son
|
|
|
|
|
absence ailleurs dans le dépôt : « grenat.invalid » et « safran.invalid »
|
|
|
|
|
n'y paraissent nulle part.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import os
|
2026-09-16 02:59:01 -04:00
|
|
|
import subprocess
|
[ADD] assistant : serveur LLM reconnu, catalogue gpt, sessions locales
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
2026-09-09 06:41:56 -04:00
|
|
|
import sys
|
|
|
|
|
import unittest
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
|
|
|
sys.path.append(
|
|
|
|
|
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
from script.todo.assistant import servers # noqa: E402
|
|
|
|
|
|
|
|
|
|
# Un hôte et un libellé qui n'existent pas : le TLD « .invalid » est réservé
|
|
|
|
|
# à cet usage, et ne peut donc pas désigner une machine du parc.
|
|
|
|
|
HOST_A = "grenat.invalid"
|
|
|
|
|
HOST_B = "safran.invalid"
|
|
|
|
|
LABEL_A = "Atelier grenat"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def a_server(**changes):
|
|
|
|
|
"""Un serveur complet, dont chaque champ se remplace par son nom."""
|
|
|
|
|
fields = {
|
|
|
|
|
"handle": "server-1",
|
|
|
|
|
"label": LABEL_A,
|
|
|
|
|
"host": HOST_A,
|
|
|
|
|
"port": 11434,
|
|
|
|
|
"software": "ollama",
|
|
|
|
|
"model": "petit-modele:7b",
|
|
|
|
|
"hosting": "lan",
|
|
|
|
|
"secret_ref": "",
|
|
|
|
|
}
|
|
|
|
|
fields.update(changes)
|
|
|
|
|
return servers.Server(**fields)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def an_entry(**changes):
|
|
|
|
|
"""Une entrée de configuration telle que `save` en écrit une."""
|
|
|
|
|
fields = {
|
|
|
|
|
"label": LABEL_A,
|
|
|
|
|
"host": HOST_A,
|
|
|
|
|
"port": 11434,
|
|
|
|
|
"software": "ollama",
|
|
|
|
|
"model": "petit-modele:7b",
|
|
|
|
|
"hosting": "lan",
|
|
|
|
|
"secret_ref": "",
|
|
|
|
|
}
|
|
|
|
|
fields.update(changes)
|
|
|
|
|
return fields
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Poignees(unittest.TestCase):
|
|
|
|
|
def test_les_poignees_sont_stables_et_commencent_a_un(self):
|
|
|
|
|
entrees = [
|
|
|
|
|
an_entry(host=HOST_A),
|
|
|
|
|
an_entry(host=HOST_B, label="Poste safran"),
|
|
|
|
|
]
|
|
|
|
|
premier = servers.load(get_config=lambda keys: entrees)
|
|
|
|
|
second = servers.load(get_config=lambda keys: entrees)
|
|
|
|
|
self.assertEqual([s.handle for s in premier], ["server-1", "server-2"])
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
[s.handle for s in second], [s.handle for s in premier]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_la_poignee_stockee_n_est_jamais_relue(self):
|
|
|
|
|
"""Une poignée écrite à la main ne doit pas pouvoir en dupliquer une
|
|
|
|
|
autre, ni porter un nom de machine."""
|
|
|
|
|
entrees = [
|
|
|
|
|
an_entry(handle="server-9", host=HOST_A),
|
|
|
|
|
an_entry(handle="server-9", host=HOST_B),
|
|
|
|
|
]
|
|
|
|
|
charges = servers.load(get_config=lambda keys: entrees)
|
|
|
|
|
self.assertEqual([s.handle for s in charges], ["server-1", "server-2"])
|
|
|
|
|
|
|
|
|
|
def test_assign_handles_ne_touche_pas_ce_qu_on_lui_donne(self):
|
|
|
|
|
original = a_server(handle="")
|
|
|
|
|
rendus = servers.assign_handles([original])
|
|
|
|
|
self.assertEqual(original.handle, "")
|
|
|
|
|
self.assertEqual(rendus[0].handle, "server-1")
|
|
|
|
|
self.assertEqual(rendus[0].host, original.host)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CeQuiAtteintUneInvite(unittest.TestCase):
|
|
|
|
|
def test_ce_qui_peut_atteindre_une_invite_ne_porte_ni_hote_ni_port(self):
|
|
|
|
|
sortie = servers.redacted(a_server())
|
|
|
|
|
self.assertIn("server-1", sortie)
|
|
|
|
|
self.assertIn("ollama", sortie)
|
|
|
|
|
for interdit in (HOST_A, LABEL_A, "11434", "grenat"):
|
|
|
|
|
self.assertNotIn(interdit, sortie, sortie)
|
|
|
|
|
|
|
|
|
|
def test_un_serveur_sans_logiciel_reconnu_rend_sa_seule_poignee(self):
|
|
|
|
|
self.assertEqual(servers.redacted(a_server(software="")), "server-1")
|
|
|
|
|
|
|
|
|
|
def test_un_serveur_sans_rang_ne_retombe_pas_sur_son_adresse(self):
|
|
|
|
|
sortie = servers.redacted(a_server(handle=""))
|
|
|
|
|
self.assertEqual(sortie, f"{servers.UNASSIGNED_HANDLE} (ollama)")
|
|
|
|
|
self.assertNotIn(HOST_A, sortie)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OuLEcritureVa(unittest.TestCase):
|
|
|
|
|
def test_l_ecriture_passe_par_le_fichier_prive_et_pas_par_un_autre(self):
|
|
|
|
|
ecrits = []
|
|
|
|
|
servers.save(
|
|
|
|
|
[a_server()],
|
|
|
|
|
set_config=lambda keys, value: ecrits.append((keys, value)),
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(len(ecrits), 1, ecrits)
|
|
|
|
|
keys, value = ecrits[0]
|
|
|
|
|
self.assertEqual(keys, ["assistant", "servers"])
|
|
|
|
|
self.assertEqual([e["host"] for e in value], [HOST_A])
|
|
|
|
|
|
|
|
|
|
# `set_config_value` vise le seul des trois fichiers fusionnés qui
|
|
|
|
|
# soit gitignored. La classe est remplacée pour prouver le défaut
|
|
|
|
|
# sans qu'aucun fichier réel soit ouvert.
|
|
|
|
|
with patch("script.config.config_file.ConfigFile") as classe:
|
|
|
|
|
servers.save([a_server()])
|
|
|
|
|
instance = classe.return_value
|
|
|
|
|
instance.set_config_value.assert_called_once()
|
|
|
|
|
appel = instance.set_config_value.call_args
|
|
|
|
|
self.assertEqual(appel.args[0], ["assistant", "servers"])
|
|
|
|
|
|
|
|
|
|
source = Path(servers.__file__).read_text(encoding="utf-8")
|
|
|
|
|
self.assertNotIn("CONFIG_OVERRIDE_FILE", source)
|
|
|
|
|
self.assertNotIn("CONFIG_FILE", source)
|
|
|
|
|
|
|
|
|
|
def test_la_lecture_par_defaut_passe_par_le_meme_chemin_de_cles(self):
|
|
|
|
|
with patch("script.config.config_file.ConfigFile") as classe:
|
|
|
|
|
classe.return_value.get_config_value.return_value = []
|
|
|
|
|
self.assertEqual(servers.load(), [])
|
|
|
|
|
classe.return_value.get_config_value.assert_called_once_with(
|
|
|
|
|
["assistant", "servers"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_l_entree_ecrite_ne_porte_ni_poignee_ni_horodatage(self):
|
|
|
|
|
"""Un rang figé sur le disque survivrait à la suppression d'un
|
|
|
|
|
voisin, et une trace de contact décrirait une machine que personne
|
|
|
|
|
n'a désignée."""
|
|
|
|
|
ecrits = []
|
|
|
|
|
servers.save(
|
|
|
|
|
[a_server()],
|
|
|
|
|
set_config=lambda keys, value: ecrits.append(value),
|
|
|
|
|
)
|
|
|
|
|
self.assertTrue(ecrits, "rien n'a été écrit")
|
|
|
|
|
for entree in ecrits[0]:
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
sorted(entree),
|
|
|
|
|
[
|
|
|
|
|
"host",
|
|
|
|
|
"hosting",
|
|
|
|
|
"label",
|
|
|
|
|
"model",
|
|
|
|
|
"port",
|
|
|
|
|
"secret_ref",
|
|
|
|
|
"software",
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_un_serveur_sans_secret_a_une_reference_vide(self):
|
|
|
|
|
charge = servers.load(
|
|
|
|
|
get_config=lambda keys: [an_entry(secret_ref=None)]
|
|
|
|
|
)
|
|
|
|
|
self.assertTrue(charge, "l'entrée aurait dû être chargée")
|
|
|
|
|
self.assertEqual(charge[0].secret_ref, "")
|
|
|
|
|
ecrits = []
|
|
|
|
|
servers.save(
|
|
|
|
|
charge, set_config=lambda keys, value: ecrits.append(value)
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(ecrits[0][0]["secret_ref"], "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Chargement(unittest.TestCase):
|
|
|
|
|
def test_une_configuration_vide_est_une_liste_vide(self):
|
|
|
|
|
self.assertEqual(servers.load(get_config=lambda keys: None), [])
|
|
|
|
|
self.assertEqual(servers.load(get_config=lambda keys: []), [])
|
|
|
|
|
self.assertEqual(servers.load(get_config=lambda keys: {}), [])
|
|
|
|
|
|
|
|
|
|
def test_une_section_absente_ne_leve_pas(self):
|
|
|
|
|
"""`get_config_value` lève `TypeError` quand la clé de tête manque :
|
|
|
|
|
c'est l'état d'une machine où rien n'a encore été configuré."""
|
|
|
|
|
|
|
|
|
|
def absente(keys):
|
|
|
|
|
raise TypeError("argument of type 'NoneType' is not iterable")
|
|
|
|
|
|
|
|
|
|
self.assertEqual(servers.load(get_config=absente), [])
|
|
|
|
|
|
|
|
|
|
def test_un_json_abime_ou_illisible_ne_leve_pas(self):
|
|
|
|
|
def abime(keys):
|
|
|
|
|
raise ValueError("Expecting value")
|
|
|
|
|
|
|
|
|
|
def illisible(keys):
|
|
|
|
|
raise OSError("Permission denied")
|
|
|
|
|
|
|
|
|
|
self.assertEqual(servers.load(get_config=abime), [])
|
|
|
|
|
self.assertEqual(servers.load(get_config=illisible), [])
|
|
|
|
|
|
|
|
|
|
def test_une_entree_corrompue_ne_fait_pas_tomber_le_chargement(self):
|
|
|
|
|
entrees = [
|
|
|
|
|
"une chaîne au lieu d'un objet",
|
|
|
|
|
None,
|
|
|
|
|
an_entry(host=""),
|
|
|
|
|
an_entry(port="onze mille"),
|
|
|
|
|
an_entry(port=True),
|
|
|
|
|
an_entry(port=99999),
|
|
|
|
|
{"host": HOST_B},
|
|
|
|
|
an_entry(host=HOST_A),
|
|
|
|
|
]
|
|
|
|
|
charges = servers.load(get_config=lambda keys: entrees)
|
|
|
|
|
self.assertEqual([s.host for s in charges], [HOST_A])
|
|
|
|
|
self.assertEqual(charges[0].handle, "server-1")
|
|
|
|
|
|
|
|
|
|
def test_un_port_en_chaine_de_chiffres_est_accepte(self):
|
|
|
|
|
charges = servers.load(get_config=lambda keys: [an_entry(port="8080")])
|
|
|
|
|
self.assertTrue(charges, "l'entrée aurait dû être chargée")
|
|
|
|
|
self.assertEqual(charges[0].port, 8080)
|
|
|
|
|
|
|
|
|
|
def test_un_hebergement_inconnu_se_lit_comme_global(self):
|
|
|
|
|
"""La lecture est pessimiste : la classe la plus exposée impose la
|
|
|
|
|
confirmation la plus stricte au lieu de la lever."""
|
|
|
|
|
for stocke in ("", "atelier", None, 3):
|
|
|
|
|
charges = servers.load(
|
|
|
|
|
get_config=lambda keys, v=stocke: [an_entry(hosting=v)]
|
|
|
|
|
)
|
|
|
|
|
self.assertTrue(charges, f"entrée écartée pour {stocke!r}")
|
|
|
|
|
self.assertEqual(charges[0].hosting, "global")
|
|
|
|
|
|
|
|
|
|
def test_un_libelle_absent_retombe_sur_l_hote(self):
|
|
|
|
|
charges = servers.load(
|
|
|
|
|
get_config=lambda keys: [an_entry(label={"a": 1})]
|
|
|
|
|
)
|
|
|
|
|
self.assertTrue(charges, "l'entrée aurait dû être chargée")
|
|
|
|
|
self.assertEqual(charges[0].label, HOST_A)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RacineDApi(unittest.TestCase):
|
|
|
|
|
def test_open_webui_ne_recoit_pas_un_v1(self):
|
|
|
|
|
for ecrit in ("open-webui", "Open WebUI", "open_webui", "openwebui"):
|
|
|
|
|
url = servers.base_url(a_server(software=ecrit, port=3000))
|
|
|
|
|
self.assertEqual(url, f"http://{HOST_A}:3000/api", ecrit)
|
|
|
|
|
self.assertNotIn("v1", url, ecrit)
|
|
|
|
|
|
|
|
|
|
def test_toute_autre_famille_parle_a_v1(self):
|
|
|
|
|
for ecrit in ("ollama", "llamacpp", "vllm", "lmstudio", ""):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
servers.base_url(a_server(software=ecrit)),
|
|
|
|
|
f"http://{HOST_A}:11434/v1",
|
|
|
|
|
ecrit,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_le_port_443_passe_en_tls(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
servers.base_url(a_server(port=443)),
|
|
|
|
|
f"https://{HOST_A}:443/v1",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LaFrontiere(unittest.TestCase):
|
|
|
|
|
def test_le_registre_ne_tire_pas_todo(self):
|
|
|
|
|
"""Importer `script.todo.todo` coûte près d'une seconde et imprime
|
|
|
|
|
sur la sortie : le paquet doit rester importable seul."""
|
2026-09-16 02:59:01 -04:00
|
|
|
# Dans un interpréteur NEUF : la suite complète importe todo par
|
|
|
|
|
# ailleurs, et le sys.modules de ce processus en garderait la trace
|
|
|
|
|
# quel que soit le module éprouvé ici.
|
|
|
|
|
sortie = subprocess.run(
|
|
|
|
|
[
|
|
|
|
|
sys.executable,
|
|
|
|
|
"-c",
|
|
|
|
|
"import sys, script.todo.assistant.servers;"
|
|
|
|
|
" print('script.todo.todo' in sys.modules)",
|
|
|
|
|
],
|
|
|
|
|
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=60,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(sortie.returncode, 0, sortie.stderr)
|
|
|
|
|
self.assertEqual(sortie.stdout.strip(), "False")
|
[ADD] assistant : serveur LLM reconnu, catalogue gpt, sessions locales
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
2026-09-09 06:41:56 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|