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
277 lines
10 KiB
Python
277 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
"""Les serveurs LLM retenus : une poignée opaque, une adresse qui ne sort pas.
|
||
|
||
Chaque serveur porte une POIGNÉE — « server-1 » — attribuée par son RANG au
|
||
chargement, et c'est la seule forme qui a le droit de circuler : `redacted`
|
||
est ce qui peut atteindre une invite, un argument de commande ou un fichier
|
||
que le dépôt suit. L'hôte, le port et le libellé servent à l'affichage du
|
||
menu et à la configuration privée, et s'arrêtent là.
|
||
|
||
La retenue est structurelle parce qu'elle ne peut pas être un filtre : le
|
||
détecteur du dépôt reconnaît les adresses, les courriels et les chemins de
|
||
compte, et rend une liste vide devant un nom d'hôte, un alias SSH ou un nom
|
||
de VM. Ce qu'aucun garde-fou ne voit passer ne doit pas être en position de
|
||
passer.
|
||
|
||
L'écriture passe par `set_config_value`, et par lui seul. Des trois fichiers
|
||
que la lecture fusionne, c'est le seul qui soit gitignored ; les deux autres
|
||
suivent le dépôt en amont, et ce qui vit sous `private/` devient public avec
|
||
lui sur un fork rendu public. Une seule section est écrite, sous le chemin de
|
||
clés « assistant › servers ».
|
||
|
||
N'est enregistré que ce que l'utilisateur a choisi de garder : ni date de
|
||
dernier contact, ni rapport de balayage, ni résultat négatif. La liste de qui
|
||
a répondu parmi les 254 adresses d'un /24 décrit des machines que personne
|
||
n'a désignées, là où un serveur retenu en désigne une seule, volontairement.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, replace
|
||
|
||
# Le chemin de clés de la section, dans les fichiers de configuration.
|
||
CONFIG_KEYS = ("assistant", "servers")
|
||
|
||
HANDLE_PREFIX = "server-"
|
||
|
||
# La poignée d'un serveur qui n'a pas encore reçu de rang. Elle garde
|
||
# l'adresse dehors là où un repli sur le libellé l'y ferait entrer.
|
||
UNASSIGNED_HANDLE = f"{HANDLE_PREFIX}?"
|
||
|
||
# L'échelle d'hébergement, du plus contenu au plus exposé.
|
||
HOSTINGS = ("loopback", "lan", "global")
|
||
|
||
# La classe d'une valeur stockée qu'on ne reconnaît pas. Lire au plus
|
||
# prudent impose la confirmation la plus stricte au lieu de la lever.
|
||
UNKNOWN_HOSTING = "global"
|
||
|
||
# La famille sans racine `/v1`, réduite à ses lettres et ses chiffres.
|
||
OPEN_WEBUI = "openwebui"
|
||
OPEN_WEBUI_ROOT = "/api"
|
||
DEFAULT_ROOT = "/v1"
|
||
|
||
HTTPS_PORT = 443
|
||
MAX_PORT = 65535
|
||
|
||
|
||
@dataclass
|
||
class Server:
|
||
"""Un serveur retenu.
|
||
|
||
`handle` vaut « server-N » et se rattribue à chaque chargement ;
|
||
`label` est ce que l'utilisateur a tapé pour le nommer, et ne sert qu'à
|
||
l'affichage. `secret_ref` est vide, ou « kdbx:<titre d'entrée> » : la
|
||
clé elle-même reste dans le coffre, jamais ici.
|
||
"""
|
||
|
||
handle: str
|
||
label: str
|
||
host: str
|
||
port: int
|
||
software: str
|
||
model: str
|
||
hosting: str
|
||
secret_ref: str
|
||
|
||
|
||
def assign_handles(servers) -> list[Server]:
|
||
"""Les mêmes serveurs, chacun portant « server-N » selon son rang.
|
||
|
||
Numérote à partir de 1, dans l'ordre de la liste reçue. Pure : rend de
|
||
nouveaux objets et laisse intacts ceux qu'on lui donne.
|
||
|
||
La poignée dérive de la seule position et jamais d'une valeur stockée :
|
||
un fichier édité à la main ne peut donc produire ni deux « server-1 »,
|
||
ni une poignée qui porterait un nom de machine.
|
||
"""
|
||
return [
|
||
replace(server, handle=f"{HANDLE_PREFIX}{rank}")
|
||
for rank, server in enumerate(servers, 1)
|
||
]
|
||
|
||
|
||
def load(*, get_config=None) -> list[Server]:
|
||
"""Les serveurs enregistrés, poignées attribuées. Jamais None.
|
||
|
||
`get_config` prend un chemin de clés et rend la valeur fusionnée des
|
||
trois fichiers de configuration ; il vaut `ConfigFile().get_config_value`
|
||
quand rien n'est injecté, résolu ici pour qu'un test n'ait aucun fichier
|
||
réel à toucher.
|
||
|
||
Rend une liste vide plutôt que de lever, dans les quatre cas où la
|
||
configuration ne porte pas de section utilisable : l'accesseur lève
|
||
`TypeError` quand la section est absente, `ValueError` sur un JSON
|
||
abîmé, `OSError` sur un fichier illisible, et rend n'importe quel type
|
||
sur un fichier édité à la main. Aucun n'est une raison d'empêcher le
|
||
menu de s'ouvrir.
|
||
|
||
Une entrée qui ne décrit pas un serveur est écartée seule : une ligne
|
||
abîmée ne fait pas disparaître les suivantes.
|
||
"""
|
||
if get_config is None:
|
||
from script.config.config_file import ConfigFile
|
||
|
||
get_config = ConfigFile().get_config_value
|
||
try:
|
||
raw = get_config(list(CONFIG_KEYS))
|
||
except (OSError, ValueError, TypeError, KeyError, AttributeError):
|
||
return []
|
||
if not isinstance(raw, list):
|
||
return []
|
||
kept = [_from_dict(entry) for entry in raw]
|
||
return assign_handles([server for server in kept if server is not None])
|
||
|
||
|
||
def save(servers, *, set_config=None) -> None:
|
||
"""Écrit la liste sous « assistant › servers », et rien d'autre.
|
||
|
||
`set_config` prend un chemin de clés et une valeur ; il vaut
|
||
`ConfigFile().set_config_value` quand rien n'est injecté. C'est le seul
|
||
écrivain autorisé : il vise le seul des trois fichiers fusionnés qui
|
||
soit gitignored, il fusionne au lieu d'écraser, et il écrit
|
||
atomiquement par un temporaire en 0600 suivi d'un `os.replace`.
|
||
|
||
La poignée n'est pas écrite : elle se rattribue au chargement, et un
|
||
rang figé sur le disque survivrait à la suppression d'un voisin.
|
||
"""
|
||
if set_config is None:
|
||
from script.config.config_file import ConfigFile
|
||
|
||
set_config = ConfigFile().set_config_value
|
||
set_config(list(CONFIG_KEYS), [_as_dict(server) for server in servers])
|
||
|
||
|
||
def base_url(server) -> str:
|
||
"""La racine d'API à laquelle parler à ce serveur.
|
||
|
||
Toutes les familles servent leur complétion sous `/v1`, sauf Open WebUI
|
||
qui n'a PAS de racine `/v1` : la sienne vit sous `/api`, la complétion à
|
||
`/api/chat/completions`, et elle exige un jeton Bearer. Une racine
|
||
`/v1` pointée sur lui rend 404 à chaque envoi.
|
||
|
||
Le schéma se déduit du port : 443 est du TLS, tout le reste du HTTP en
|
||
clair, qui est ce qu'un serveur de modèle sert par défaut.
|
||
"""
|
||
scheme = "https" if server.port == HTTPS_PORT else "http"
|
||
root = (
|
||
OPEN_WEBUI_ROOT
|
||
if _family(server.software) == OPEN_WEBUI
|
||
else DEFAULT_ROOT
|
||
)
|
||
return f"{scheme}://{server.host}:{server.port}{root}"
|
||
|
||
|
||
def redacted(server) -> str:
|
||
"""Ce que ce serveur a le droit de devenir dans une invite.
|
||
|
||
Rend « server-1 (ollama) », ou la seule poignée quand le logiciel n'a
|
||
pas été reconnu. La poignée et le nom du logiciel ne désignent personne.
|
||
|
||
L'hôte, le port et le libellé n'en sortent jamais : le libellé est ce
|
||
que l'opérateur a tapé, donc un alias ou un nom de machine aussi souvent
|
||
qu'autre chose. Un serveur sans rang rend `UNASSIGNED_HANDLE` plutôt que
|
||
de combler le trou avec son adresse.
|
||
"""
|
||
handle = server.handle or UNASSIGNED_HANDLE
|
||
software = _text(server.software)
|
||
return f"{handle} ({software})" if software else handle
|
||
|
||
|
||
def _family(software) -> str:
|
||
"""Le nom d'un logiciel réduit à ses lettres et ses chiffres, en bas de
|
||
casse.
|
||
|
||
L'échelle de reconnaissance nomme « Open WebUI » ; l'espace, le tiret et
|
||
la casse varient d'une source à l'autre sans changer la famille, et
|
||
comparer les chaînes brutes ferait dépendre la racine d'API d'un tiret.
|
||
"""
|
||
return "".join(c for c in (software or "").lower() if c.isalnum())
|
||
|
||
|
||
def _from_dict(entry):
|
||
"""Un serveur lu depuis la configuration, ou None si l'entrée n'en
|
||
décrit pas un.
|
||
|
||
Exige un hôte et un port utilisables — sans eux il n'y a rien à
|
||
joindre — et se contente du reste tel qu'il vient. La poignée stockée
|
||
est IGNORÉE : elle se rattribue par le rang.
|
||
|
||
Un libellé absent retombe sur l'hôte, comme la saisie du menu le fait
|
||
déjà : le libellé ne sert qu'à l'affichage, où l'adresse a le droit de
|
||
paraître.
|
||
"""
|
||
if not isinstance(entry, dict):
|
||
return None
|
||
host = _text(entry.get("host"))
|
||
port = _port(entry.get("port"))
|
||
if not host or port is None:
|
||
return None
|
||
return Server(
|
||
handle="",
|
||
label=_text(entry.get("label")) or host,
|
||
host=host,
|
||
port=port,
|
||
software=_text(entry.get("software")),
|
||
model=_text(entry.get("model")),
|
||
hosting=_hosting(entry.get("hosting")),
|
||
secret_ref=_text(entry.get("secret_ref")),
|
||
)
|
||
|
||
|
||
def _as_dict(server) -> dict:
|
||
"""L'entrée écrite pour un serveur : ce qui a été choisi, rien de plus.
|
||
|
||
Sept champs, tous fournis par l'utilisateur ou par la reconnaissance du
|
||
serveur qu'il a désigné. Aucun horodatage, aucune trace de contact : ce
|
||
fichier dit ce qu'on garde, pas ce qu'on a vu.
|
||
"""
|
||
return {
|
||
"label": server.label,
|
||
"host": server.host,
|
||
"port": server.port,
|
||
"software": server.software,
|
||
"model": server.model,
|
||
"hosting": server.hosting,
|
||
"secret_ref": server.secret_ref,
|
||
}
|
||
|
||
|
||
def _text(value) -> str:
|
||
"""La valeur quand c'est une chaîne, sans ses espaces de bord ; sinon "".
|
||
|
||
Une valeur d'un autre type est jetée plutôt que passée par `str()` :
|
||
la représentation d'un dictionnaire ou d'une liste entrerait dans un
|
||
libellé et de là dans l'affichage.
|
||
"""
|
||
return value.strip() if isinstance(value, str) else ""
|
||
|
||
|
||
def _port(value):
|
||
"""Le numéro de port, ou None quand la valeur n'en est pas un.
|
||
|
||
`bool` est un `int` pour Python : sans le refus explicite, `true`
|
||
deviendrait le port 1. Une chaîne de chiffres est acceptée parce qu'un
|
||
fichier de configuration édité à la main en porte volontiers une.
|
||
"""
|
||
if isinstance(value, bool):
|
||
return None
|
||
if isinstance(value, str):
|
||
value = value.strip()
|
||
if not value.isdigit():
|
||
return None
|
||
value = int(value)
|
||
if not isinstance(value, int):
|
||
return None
|
||
return value if 1 <= value <= MAX_PORT else None
|
||
|
||
|
||
def _hosting(value) -> str:
|
||
"""La classe d'hébergement stockée, « global » si elle n'est pas connue.
|
||
|
||
La lecture est pessimiste : une valeur absente ou abîmée vaut tiers, ce
|
||
qui impose au premier envoi la confirmation la plus stricte au lieu de
|
||
la lever.
|
||
"""
|
||
text = _text(value)
|
||
return text if text in HOSTINGS else UNKNOWN_HOSTING
|