Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier, d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient presque entièrement aux chaînes coupées à la main que ruff recolle quand elles tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune valeur ne change, et les clés de traduction non plus. Vérifié : la suite unitaire reste verte après le passage, et le contrôle de syntaxe ne signale rien. --- EN --- This repository's formatter is ruff since it replaced black, which knows no target beyond py313; this pass applies its standard to the whole tree at once, so that no substantive commit has to carry style. The difference is almost entirely the hand-split strings ruff joins back when they fit on one line, and the multiple "with" it merges: no value changes, nor do the translation keys. Checked: the unit suite stays green after the pass, and the syntax check reports nothing. Assisted-by: Claude Opus 5
480 lines
18 KiB
Python
480 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
"""Qui répond sur un port : l'échelle de reconnaissance, et son transport.
|
|
|
|
Un port dit OÙ frapper, jamais QUI répond : 8080 héberge llama.cpp, LocalAI et
|
|
Open WebUI, 5000 héberge text-generation-webui et TabbyAPI, et `/v1/models`
|
|
est servi par onze serveurs sur douze. L'identité se lit donc dans le CORPS
|
|
d'une réponse, dans un ordre fixe, et le premier accord arrête l'échelle.
|
|
|
|
Cet ordre porte tout le raisonnement, et son premier étage en est la raison :
|
|
LocalAI réémet l'API native d'Ollama EN ENTIER — `/api/tags`, `/api/show`,
|
|
`/api/ps`, `/api/version` — et rend jusqu'à la chaîne « Ollama is running »
|
|
sur « / ». Les points de terminaison propres à Ollama n'identifient donc pas
|
|
Ollama. LocalAI s'écarte le PREMIER, par `GET /readyz`, qu'Ollama ne possède
|
|
pas et où il rend 404 ; l'étage Ollama n'est atteignable que parce que cet
|
|
écart a déjà eu lieu.
|
|
|
|
Le module tient deux moitiés qui ne se mélangent pas. `identify` est PUR : il
|
|
ne reçoit que des octets déjà lus, donc l'ordre de l'échelle se vérifie sans
|
|
ouvrir une socket. `collect` ne fait que le transport, donc le plafond de
|
|
lecture et les délais se vérifient contre un serveur qui se conduit mal.
|
|
|
|
La découverte n'émet que des GET, sans corps et sans en-tête `Authorization` :
|
|
un balayage ne doit pouvoir ni charger un modèle, ni dépenser un jeton. Le
|
|
`POST /api/show` d'Ollama appartient à l'interrogation des capacités, lancée
|
|
après que l'utilisateur a choisi un serveur.
|
|
|
|
Trois réponses que le transport rend comme des RÉSULTATS et non des échecs :
|
|
un 503 « starting » ou « Loading model » est vivant et identifié, un 401 est
|
|
un accord de reconnaissance et jamais une invitation à saisir une clé, et un
|
|
corps tronqué vaut ce qui en est arrivé.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import http.client
|
|
import json
|
|
import re
|
|
import socket
|
|
import time
|
|
import urllib.parse
|
|
from dataclasses import dataclass
|
|
from typing import Callable
|
|
|
|
# Les ports à frapper, dans cet ordre. Le port ne nomme rien : il ne fait
|
|
# qu'ouvrir la question que l'échelle tranche.
|
|
PORTS: tuple[int, ...] = (
|
|
11434,
|
|
1234,
|
|
5001,
|
|
1337,
|
|
4891,
|
|
8080,
|
|
5000,
|
|
8000,
|
|
3000,
|
|
8081,
|
|
5002,
|
|
)
|
|
|
|
# Le plafond de lecture par réponse. Une page d'administration de routeur ou
|
|
# un catalogue de plusieurs milliers de modèles répond volontiers à ces
|
|
# chemins ; la reconnaissance se joue dans les premiers octets.
|
|
BODY_CAP = 8192
|
|
|
|
# GPT4All n'expose aucun point de terminaison qui lui soit propre : son étage
|
|
# ne s'atteint que par élimination, et seulement sur ce port.
|
|
GPT4ALL_PORT = 4891
|
|
|
|
# OpenAI distant se tranche par le nom d'hôte. Un scan ne le touche jamais :
|
|
# il coûte un jeton et n'est pas sur le réseau qu'on balaie.
|
|
OPENAI_HOST = "api.openai.com"
|
|
|
|
OLLAMA_ROOT = b"Ollama is running"
|
|
JAN_TITLE = "Jan API Server Endpoints"
|
|
|
|
# Un numéro de version plausible. L'étage Ollama s'en sert pour confirmer que
|
|
# `/api/version` répond bien ce qu'Ollama y répond, et non le JSON d'autre
|
|
# chose monté au même endroit.
|
|
SEMVER = re.compile(r"^\d+\.\d+")
|
|
|
|
# Ce qui prouve que rien n'écoute : les chemins suivants seraient refusés de
|
|
# la même façon, donc la collecte s'arrête au lieu de recommencer quinze fois
|
|
# par hôte mort.
|
|
DEAD = (ConnectionRefusedError, socket.gaierror)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Fingerprint:
|
|
"""Ce qu'une réponse a prouvé, et ce qu'elle n'a pas dit.
|
|
|
|
`software` est la chaîne vide quand aucun étage n'a reconnu quoi que ce
|
|
soit : un point de terminaison génériquement compatible OpenAI en est un
|
|
cas normal, pas une panne. `unknown` nomme les champs que les corps ne
|
|
portaient pas, parmi « software », « version » et « models » — de quoi
|
|
afficher « ? » sur ceux-là plutôt que de deviner.
|
|
"""
|
|
|
|
software: str = ""
|
|
version: str = ""
|
|
models: tuple[str, ...] = ()
|
|
unknown: frozenset[str] = frozenset()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Probe:
|
|
"""Un étage : le logiciel qu'il nomme, ce qu'il lit, ce qui l'accorde.
|
|
|
|
`decide(bodies, port, host)` rend la version lue, la chaîne vide quand le
|
|
corps ne la porte pas, et `None` quand l'étage ne reconnaît rien.
|
|
Distinguer « accord sans version » de « pas d'accord » est ce qui permet
|
|
de nommer un serveur dont le numéro reste inconnu.
|
|
"""
|
|
|
|
software: str
|
|
paths: tuple[str, ...]
|
|
decide: Callable[[dict[str, tuple[int, bytes]], int, str], str | None]
|
|
|
|
|
|
def _answer(
|
|
bodies: dict[str, tuple[int, bytes]], path: str
|
|
) -> tuple[int, bytes] | None:
|
|
"""Le couple (statut, octets) d'un chemin, ou `None`.
|
|
|
|
Un chemin absent de la table n'a pas été sondé ou n'a rien rendu : les
|
|
deux se lisent pareil, et aucun étage ne doit distinguer les deux.
|
|
"""
|
|
answer = bodies.get(path)
|
|
if not isinstance(answer, tuple) or len(answer) != 2:
|
|
return None
|
|
return answer
|
|
|
|
|
|
def _json(
|
|
bodies: dict[str, tuple[int, bytes]],
|
|
path: str,
|
|
*,
|
|
statuses: tuple[int, ...] = (200,),
|
|
) -> object:
|
|
"""Le corps d'un chemin analysé en JSON, ou `None`.
|
|
|
|
Rend `None` sur un statut non voulu, sur du HTML, sur un corps vide et
|
|
sur un corps coupé en plein milieu : chaque analyse est enveloppée parce
|
|
qu'un portail captif répond 200 en HTML à n'importe quel chemin.
|
|
"""
|
|
answer = _answer(bodies, path)
|
|
if answer is None or answer[0] not in statuses:
|
|
return None
|
|
try:
|
|
return json.loads(answer[1])
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _entries(data: object) -> list[dict]:
|
|
"""Les entrées d'une liste OpenAI `{"data": [...]}`, sinon une liste vide.
|
|
|
|
Ne garde que les éléments qui sont des mappings : une liste d'identifiants
|
|
nus ne porte aucun champ à lire.
|
|
"""
|
|
if not isinstance(data, dict):
|
|
return []
|
|
entries = data.get("data")
|
|
if not isinstance(entries, list):
|
|
return []
|
|
return [entry for entry in entries if isinstance(entry, dict)]
|
|
|
|
|
|
def _text(data: object, key: str) -> str:
|
|
"""La valeur textuelle d'une clé d'un mapping, sinon la chaîne vide."""
|
|
if isinstance(data, dict):
|
|
value = data.get(key)
|
|
if isinstance(value, str):
|
|
return value
|
|
return ""
|
|
|
|
|
|
# Étage 1 — LocalAI. `/readyz` est le seul point que LocalAI possède et
|
|
# qu'Ollama ignore : Ollama y rend 404. La version ne se lit PAS ici, et pas
|
|
# ailleurs non plus : LocalAI annonce un littéral figé sur `/api/version`,
|
|
# indépendant de sa propre version, et ce numéro-là existe aussi comme
|
|
# version réelle d'Ollama — il ne sépare donc rien à lui seul.
|
|
def _localai(bodies, port, host):
|
|
answer = _answer(bodies, "/readyz")
|
|
if answer is None:
|
|
return None
|
|
status, body = answer
|
|
if status == 200 and not body.strip():
|
|
return ""
|
|
if status == 503 and _text(
|
|
_json(bodies, "/readyz", statuses=(503,)), "status"
|
|
):
|
|
# Le préchargement d'un modèle : le serveur est identifié et vivant,
|
|
# il n'est pas encore prêt à répondre.
|
|
return ""
|
|
return None
|
|
|
|
|
|
# Étage 2 — KoboldCpp se nomme lui-même dans le champ `result`.
|
|
def _koboldcpp(bodies, port, host):
|
|
data = _json(bodies, "/api/extra/version")
|
|
if _text(data, "result") == "KoboldCpp":
|
|
return _text(data, "version")
|
|
return None
|
|
|
|
|
|
# Étage 3 — Jan se nomme dans le titre de son schéma OpenAPI.
|
|
def _jan(bodies, port, host):
|
|
data = _json(bodies, "/openapi.json")
|
|
if not isinstance(data, dict):
|
|
return None
|
|
info = data.get("info")
|
|
if _text(info, "title") == JAN_TITLE:
|
|
return _text(info, "version")
|
|
return None
|
|
|
|
|
|
# Étage 4 — Open WebUI est la seule interface à publier `deployment_id`.
|
|
def _open_webui(bodies, port, host):
|
|
data = _json(bodies, "/api/config")
|
|
if isinstance(data, dict) and "deployment_id" in data:
|
|
return _text(data, "version")
|
|
return None
|
|
|
|
|
|
# Étage 5 — llama.cpp. Les deux clés sont exigées ENSEMBLE : `build_info` seul
|
|
# se retrouve sur des empaquetages qui recopient le champ, et
|
|
# `chat_template_caps` est ce que le serveur amont sert vraiment sur `/props`.
|
|
def _llamacpp(bodies, port, host):
|
|
data = _json(bodies, "/props")
|
|
if not isinstance(data, dict):
|
|
return None
|
|
if "build_info" in data and "chat_template_caps" in data:
|
|
return _text(data, "build_info")
|
|
return None
|
|
|
|
|
|
# Étage 6 — vLLM. Le chemin est `/version`, PAS `/api/version` : ce dernier
|
|
# appartient à Ollama et à LocalAI, et les confondre nomme vLLM sur toutes
|
|
# les machines Ollama.
|
|
def _vllm(bodies, port, host):
|
|
data = _json(bodies, "/version")
|
|
if isinstance(data, dict) and "version" in data:
|
|
return _text(data, "version")
|
|
return None
|
|
|
|
|
|
# Étage 7 — LM Studio. Son catalogue porte des champs que la forme OpenAI
|
|
# n'a pas ; l'ancien chemin `/api/v0/models` et le nouveau se lisent pareil.
|
|
def _lmstudio(bodies, port, host):
|
|
for path in ("/api/v0/models", "/api/v1/models"):
|
|
for entry in _entries(_json(bodies, path)):
|
|
if "compatibility_type" in entry or "max_context_length" in entry:
|
|
return ""
|
|
return None
|
|
|
|
|
|
# Étage 8 — Ollama, atteignable seulement parce que l'étage 1 a écarté
|
|
# LocalAI. Le catalogue et la racine sont exigés ENSEMBLE, et `/api/version`
|
|
# confirme sans jamais être exigé : une confirmation absente laisse l'accord
|
|
# debout, seul un champ `version` qui ne ressemble pas à un numéro le retire —
|
|
# c'est alors que du JSON étranger est monté sous ce chemin.
|
|
def _ollama(bodies, port, host):
|
|
tags = _json(bodies, "/api/tags")
|
|
if not isinstance(tags, dict) or not isinstance(tags.get("models"), list):
|
|
return None
|
|
root = _answer(bodies, "/")
|
|
if root is None or root[0] != 200 or OLLAMA_ROOT not in root[1]:
|
|
return None
|
|
data = _json(bodies, "/api/version")
|
|
if not isinstance(data, dict) or "version" not in data:
|
|
return ""
|
|
version = _text(data, "version")
|
|
return version if SEMVER.match(version) else None
|
|
|
|
|
|
# Étage 9 — text-generation-webui, par un chemin interne qu'il est seul à
|
|
# monter sous `/v1`.
|
|
def _textgen_webui(bodies, port, host):
|
|
data = _json(bodies, "/v1/internal/model/info")
|
|
if isinstance(data, dict) and data:
|
|
return ""
|
|
return None
|
|
|
|
|
|
# Étage 10 — TabbyAPI. `/v1/model` au singulier n'existe que chez lui, et il
|
|
# exige une clé par défaut : un 401 est donc un ACCORD de reconnaissance. Le
|
|
# 200 couvre la configuration qui a désactivé l'authentification.
|
|
def _tabbyapi(bodies, port, host):
|
|
for path in ("/v1/model", "/v1/template/list"):
|
|
answer = _answer(bodies, path)
|
|
if answer is not None and answer[0] == 401:
|
|
return ""
|
|
if isinstance(_json(bodies, "/v1/model"), dict):
|
|
return ""
|
|
return None
|
|
|
|
|
|
# Étage 11 — llama.cpp derrière un mandataire inverse, qui ne publie souvent
|
|
# que `/v1`. Le champ `owned_by` survit au masquage de `/props`.
|
|
def _llamacpp_proxy(bodies, port, host):
|
|
for entry in _entries(_json(bodies, "/v1/models")):
|
|
if entry.get("owned_by") == "llamacpp":
|
|
return ""
|
|
return None
|
|
|
|
|
|
# Étage 12 — GPT4All, par élimination : rien au-dessus n'a reconnu, le port
|
|
# est le sien, et une liste OpenAI est bien là. Le port seul ne suffit pas —
|
|
# une page d'administration écoute aussi sur des ports d'application.
|
|
def _gpt4all(bodies, port, host):
|
|
if port != GPT4ALL_PORT:
|
|
return None
|
|
data = _json(bodies, "/v1/models")
|
|
if isinstance(data, dict) and isinstance(data.get("data"), list):
|
|
return ""
|
|
return None
|
|
|
|
|
|
# Étage 13 — OpenAI distant, tranché par le nom d'hôte. Aucun balayage ne
|
|
# l'atteint : c'est la configuration qui le nomme.
|
|
def _openai(bodies, port, host):
|
|
if host.strip().lower().rstrip(".") == OPENAI_HOST:
|
|
return ""
|
|
return None
|
|
|
|
|
|
# L'échelle, dans l'ordre où elle est lue, arrêt au premier accord. LocalAI
|
|
# EN PREMIER : déplacer cet étage plus bas nomme « ollama » toutes les
|
|
# machines LocalAI, puisque LocalAI sert l'API native d'Ollama en entier.
|
|
LADDER: tuple[Probe, ...] = (
|
|
Probe("localai", ("/readyz",), _localai),
|
|
Probe("koboldcpp", ("/api/extra/version",), _koboldcpp),
|
|
Probe("jan", ("/openapi.json",), _jan),
|
|
Probe("open_webui", ("/api/config",), _open_webui),
|
|
Probe("llamacpp", ("/props",), _llamacpp),
|
|
Probe("vllm", ("/version",), _vllm),
|
|
Probe("lmstudio", ("/api/v0/models", "/api/v1/models"), _lmstudio),
|
|
Probe("ollama", ("/api/tags", "/", "/api/version"), _ollama),
|
|
Probe("textgen_webui", ("/v1/internal/model/info",), _textgen_webui),
|
|
Probe("tabbyapi", ("/v1/model", "/v1/template/list"), _tabbyapi),
|
|
Probe("llamacpp", ("/v1/models",), _llamacpp_proxy),
|
|
Probe("gpt4all", ("/v1/models",), _gpt4all),
|
|
Probe("openai", (), _openai),
|
|
)
|
|
|
|
|
|
def probe_plan() -> list[tuple[str, str]]:
|
|
"""Les requêtes de la découverte, dans l'ordre de l'échelle, sans doublon.
|
|
|
|
Rend des couples (méthode, chemin). La méthode est toujours GET, et elle
|
|
figure dans le plan pour que l'exiger reste une contrainte lisible : un
|
|
étage qui aurait besoin d'un autre verbe devrait aussi toucher le
|
|
transport, qui ne sait faire que GET.
|
|
"""
|
|
plan: list[tuple[str, str]] = []
|
|
seen: set[str] = set()
|
|
for probe in LADDER:
|
|
for path in probe.paths:
|
|
if path not in seen:
|
|
seen.add(path)
|
|
plan.append(("GET", path))
|
|
return plan
|
|
|
|
|
|
def _models(bodies: dict[str, tuple[int, bytes]]) -> tuple[str, ...]:
|
|
"""Les noms de modèles annoncés, dédoublonnés, dans l'ordre de lecture.
|
|
|
|
Se lit même quand aucun étage n'a reconnu le serveur : une liste de
|
|
modèles est utile devant un point de terminaison anonyme.
|
|
"""
|
|
names: list[str] = []
|
|
for path in ("/v1/models", "/api/v0/models", "/api/v1/models"):
|
|
for entry in _entries(_json(bodies, path)):
|
|
name = _text(entry, "id")
|
|
if name and name not in names:
|
|
names.append(name)
|
|
tags = _json(bodies, "/api/tags")
|
|
if isinstance(tags, dict) and isinstance(tags.get("models"), list):
|
|
for entry in tags["models"]:
|
|
name = _text(entry, "name")
|
|
if name and name not in names:
|
|
names.append(name)
|
|
return tuple(names)
|
|
|
|
|
|
def identify(
|
|
bodies: dict[str, tuple[int, bytes]], *, port: int = 0, host: str = ""
|
|
) -> Fingerprint:
|
|
"""Qui répond, lu dans les corps déjà collectés. Fonction PURE.
|
|
|
|
`bodies` associe un chemin à (statut, octets bruts) ; un chemin absent
|
|
n'a pas été sondé ou n'a rien rendu. `port` ne sert qu'à l'étage
|
|
d'élimination et `host` qu'à l'étage nommé par configuration : aucun des
|
|
deux ne peut nommer un logiciel que le corps n'a pas prouvé.
|
|
|
|
Ne lève jamais. Un corps vide, tronqué, HTML ou hostile rend une
|
|
empreinte sans logiciel, ce qui est un résultat.
|
|
"""
|
|
models = _models(bodies)
|
|
for probe in LADDER:
|
|
version = probe.decide(bodies, port, host)
|
|
if version is None:
|
|
continue
|
|
unknown = set()
|
|
if not version:
|
|
unknown.add("version")
|
|
if not models:
|
|
unknown.add("models")
|
|
return Fingerprint(probe.software, version, models, frozenset(unknown))
|
|
unknown = {"software", "version"}
|
|
if not models:
|
|
unknown.add("models")
|
|
return Fingerprint("", "", models, frozenset(unknown))
|
|
|
|
|
|
def _http_get(
|
|
url: str, timeout: float, *, max_bytes: int = BODY_CAP
|
|
) -> tuple[int, bytes]:
|
|
"""Un GET de la bibliothèque standard, dont le corps est PLAFONNÉ.
|
|
|
|
Rend (statut, octets). Ne monte ni `Authorization`, ni corps, ni verbe
|
|
autre que GET. Le plafond exige de lire la réponse par morceaux, ce que
|
|
`requests` ne donne pas simplement : un serveur qui annonce huit
|
|
mégaoctets ne doit pas en faire tenir huit en mémoire du menu.
|
|
"""
|
|
parts = urllib.parse.urlsplit(url)
|
|
conn = http.client.HTTPConnection(
|
|
parts.hostname or "", parts.port or 80, timeout=timeout
|
|
)
|
|
try:
|
|
conn.request("GET", parts.path or "/")
|
|
response = conn.getresponse()
|
|
return response.status, response.read(max_bytes)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def collect(
|
|
host: str,
|
|
port: int,
|
|
*,
|
|
http_get: Callable[[str, float], tuple[int, bytes]] | None = None,
|
|
budget: float = 1.0,
|
|
max_bytes: int = BODY_CAP,
|
|
) -> dict[str, tuple[int, bytes]]:
|
|
"""Frappe le plan et rend les corps arrivés. Le transport, et rien d'autre.
|
|
|
|
`http_get(url, timeout)` rend (statut, octets) et se remplace en test ;
|
|
la réalisation par défaut passe par la bibliothèque standard pour pouvoir
|
|
plafonner la lecture. `budget` est le TOTAL de la collecte : le délai de
|
|
chaque requête est ce qu'il en reste, donc un écouteur bloqué coûte le
|
|
budget une fois et non une fois par chemin.
|
|
|
|
Ne lève pas pour un port mort, une page HTML, un 401, un 503 ou un corps
|
|
coupé : ce sont des résultats, et l'appelant les lit par `identify`. Un
|
|
chemin absent du dictionnaire n'a rien rendu.
|
|
"""
|
|
if http_get is None:
|
|
http_get = functools.partial(_http_get, max_bytes=max_bytes)
|
|
bodies: dict[str, tuple[int, bytes]] = {}
|
|
deadline = time.monotonic() + budget
|
|
for _method, path in probe_plan():
|
|
left = deadline - time.monotonic()
|
|
if left <= 0:
|
|
break
|
|
try:
|
|
status, body = http_get(f"http://{host}:{port}{path}", left)
|
|
except DEAD:
|
|
break
|
|
except Exception:
|
|
# Un délai dépassé, une réponse illisible, une coupure : le
|
|
# chemin reste absent et les suivants gardent leur chance.
|
|
continue
|
|
# Le plafond est celui du collecteur, pas celui du transport : un
|
|
# `http_get` injecté qui l'ignorerait ne remplit pas la mémoire.
|
|
bodies[path] = (status, bytes(body[:max_bytes]))
|
|
return bodies
|