erplibre/script/todo/assistant/capabilities.py
Mathieu Benoit 8fc026c5c7 [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 07:35:15 -04:00

663 lines
24 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce qu'un serveur ANNONCE savoir faire, et ce qu'un gpt exige de lui.
La lecture des capacités a trois degrés d'honnêteté, et les confondre est le
mode de défaillance qui compte. AUTOMATIQUE — Ollama, LocalAI par sa
compatibilité Ollama, llama.cpp — publie ses capacités, sa longueur de
contexte et son nombre de paramètres : la lecture est un fait. PARTIEL — LM
Studio donne le contexte et la vision mais aucun drapeau d'outils, KoboldCpp
des booléens à l'échelle du serveur mais aucun contexte. MUET — vLLM,
text-generation-webui, Jan, Open WebUI, TabbyAPI, GPT4All et l'API OpenAI
distante, dont le schéma de modèle ne porte aucun champ de capacité : TOUT
vaut alors `None`, et c'est le cas du point de terminaison le plus courant.
Un champ vaut `None` tant qu'il n'a pas été LU dans une réponse. Une valeur
tirée du NOM d'un modèle est un indice et non une lecture : elle est remplie,
et son nom entre dans `estimated`.
D'où la règle que `match` applique : **l'inconnu ne grise jamais**. Seule une
exigence contredite par une valeur réellement lue rend « no ». Un champ vide
rend « unknown » : l'entrée reste lançable, et l'exigence se répète par son
nom au moment de l'envoi. Une valeur estimée ne grise pas davantage —
refuser un montage qui fonctionne sur une devinette est un refus que
l'utilisateur ne peut pas discuter. Griser sur l'inconnu viderait le
catalogue devant un serveur générique compatible OpenAI, qui n'annonce rien.
`hosting` est la seule exigence qui ne peut jamais être inconnue : elle ne
demande aucune requête, donc elle peut griser dès le premier contact. Elle
porte deux pièges. `ipaddress` rapporte la boucle locale comme privée AUSSI,
d'où une échelle qui teste `is_loopback` d'abord, `is_private` ensuite. Et
`ip_address` lève sur un nom d'hôte : un nom se résout d'abord, et un nom qui
ne résout pas se lit comme `global`, la lecture pessimiste, jamais comme
satisfait.
"""
from __future__ import annotations
import ipaddress
import json
import re
import socket
import urllib.request
from dataclasses import dataclass, replace
from script.todo.assistant.fingerprint import Fingerprint
# Les six clés d'exigence, dans l'ordre où une raison se rend. `hosting`
# d'abord parce que c'est la seule qui grise sans avoir parlé au serveur.
REQUIREMENT_KEYS = (
"hosting",
"context_window",
"parameters",
"tool_calling",
"vision",
"json_output",
)
# L'échelle d'hébergement est ORDONNÉE : loopback satisfait lan, qui satisfait
# any. Une seule clé exprime « doit être local » ET « pas de tiers », qui sont
# deux demandes différentes — une machine du réseau local n'est ni l'une ni
# l'autre.
HOSTING_ORDER = ("loopback", "lan", "global")
ANY_HOSTING = "any"
DEFAULT_HOSTING = "lan"
# Les raisons sont des CLÉS i18n, et `t()` rend une clé absente inchangée : une
# clé oubliée s'affiche en anglais correct. Les `%s` se remplissent par
# l'appelant, qui tient déjà `requires` et les capacités.
REASON_HOSTING = "asks for a local server, this one is %s"
REASON_CONTEXT = "asks for %s of context, this server announces %s"
REASON_PARAMETERS = "asks for %s B of parameters, this server announces %s B"
REASON_TOOLS = "asks for tool calling, this server does not announce it"
REASON_VISION = "asks for vision, this server does not announce it"
REASON_JSON = "asks for JSON output, this server does not announce it"
# La raison d'un « unknown » NOMME l'exigence qu'on n'a pas pu vérifier :
# l'envoi la répète, et « une exigence non vérifiée » sans son nom n'apprend
# rien. Les six chaînes sont écrites une par une pour qu'un balayage des clés
# i18n les trouve, là où une clé assemblée à l'exécution serait invisible.
REASON_UNCHECKED = {
"hosting": "hosting could not be checked",
"context_window": "context_window could not be checked on this server",
"parameters": "parameters could not be checked on this server",
"tool_calling": "tool_calling could not be checked on this server",
"vision": "vision could not be checked on this server",
"json_output": "json_output could not be checked on this server",
}
REASON_UNCHECKED_OTHER = "a requirement could not be checked"
# Le corps d'une réponse de capacités pèse quelques kilo-octets. Le plafond
# existe pour qu'un mauvais chemin — une page d'administration, un flux qui ne
# finit pas — ne puisse pas remplir la mémoire.
BODY_LIMIT = 1_000_000
# La lecture arrive après que l'utilisateur a désigné un serveur : elle peut
# attendre plus longtemps qu'un balayage, et charger un modèle prend du temps.
BUDGET = 3.0
# Un nombre suivi de « b » dans un texte : « 7.2B » d'un serveur, « 7b » d'un
# nom de modèle. La barrière devant le nombre écarte le numéro de version d'un
# nom comme « modele2.5:7b », où le chiffre voulu est le dernier.
PARAMETER_SIZE = re.compile(r"(?<![\d.])(\d+(?:[.,]\d+)?)\s*[bB](?!\w)")
# Deux noms coexistent selon la version de llama.cpp pour le même drapeau.
LLAMACPP_TOOL_FLAGS = ("supports_tools", "supports_tool_calls")
# L'énumération d'Ollama nomme un modèle qui lit une image de deux façons.
# N'en reconnaître qu'une rendrait « pas de vision » à un modèle qui en a, et
# griserait un gpt sur un détail de vocabulaire.
VISION_MARKS = ("vision", "image")
@dataclass(frozen=True)
class Capabilities:
"""Ce que le serveur a annoncé, champ par champ.
Chaque champ vaut `None` quand il n'a pas été lu dans une réponse — jamais
`False`, qui serait une négation lue. `estimated` porte le nom des champs
remplis par déduction plutôt que par lecture ; `match` les traite comme
inconnus, et l'affichage les marque.
"""
context_window: int | None = None
parameters: float | None = None
tool_calling: bool | None = None
vision: bool | None = None
json_output: bool | None = None
estimated: frozenset[str] = frozenset()
# Ce qu'un serveur muet rend, et ce que rend un logiciel non reconnu.
NOTHING_ANNOUNCED = Capabilities()
def classify_hosting(host: str, *, resolve=None) -> str:
"""La classe d'hébergement de `host` : loopback, lan ou global.
Rend toujours l'une des trois, et la plus pessimiste quand un nom porte
plusieurs adresses : une destination n'est locale que si toutes ses
adresses le sont.
`resolve(host)` rend les adresses en chaînes et vaut `None` par défaut,
résolu au résolveur du système. Un résolveur qui échoue, un nom qui ne
résout pas, un hôte vide comptent pour `global` — la lecture pessimiste
est la seule qui ne présente jamais un tiers comme local.
"""
addresses = _addresses(host, resolve)
if not addresses:
return HOSTING_ORDER[-1]
return HOSTING_ORDER[max(_rank_of_address(a) for a in addresses)]
def match(requires: dict, caps: Capabilities, hosting: str) -> tuple[str, str]:
"""L'appariement d'un gpt à un serveur : (verdict, clé de raison).
Le verdict est « ok », « unknown » ou « no », et la raison une clé i18n,
vide sur « ok ». Un « no » l'emporte sur un « unknown », et à verdict égal
c'est la première exigence de `REQUIREMENT_KEYS` qui donne la raison.
`requires["hosting"]` vaut `lan` en son absence : un gpt qui ne dit rien
n'autorise pas pour autant un tiers. Les autres clés absentes ne sont pas
des exigences. Une clé hors de l'ensemble fermé rend « unknown » plutôt que
d'être ignorée en silence.
"""
requires = requires or {}
verdicts = [
_check(key, requires, caps, hosting)
for key in _requirement_order(requires)
]
for wanted in ("no", "unknown"):
for verdict, reason in verdicts:
if verdict == wanted:
return verdict, reason
return "ok", ""
def read(
fingerprint: Fingerprint,
host: str,
port: int,
*,
http_post=None,
http_get=None,
) -> Capabilities:
"""Les capacités du modèle reconnu par `fingerprint` sur `host:port`.
Ne consulte de l'empreinte que `software`, qui choisit le lecteur, et
`models`, dont le premier nom désigne le modèle interrogé. Ne lève jamais :
un corps qui n'est pas du JSON, un statut autre que 200, un champ absent
laissent le champ à `None`, parce qu'un menu qui plante sur une réponse
inattendue est pire qu'un menu qui dit ne pas savoir.
`http_get(host, port, path)` et `http_post(host, port, path, payload)`
rendent `(statut, corps)` ou `None`, valent `None` par défaut et se
résolvent au transport de ce module. Ce transport est le seul du paquet à
émettre un POST : la découverte n'émet que des GET pour qu'un balayage ne
puisse jamais déclencher une génération, alors que la lecture des capacités
arrive après qu'un serveur a été désigné.
"""
software = getattr(fingerprint, "software", "") or ""
models = tuple(getattr(fingerprint, "models", ()) or ())
# Muet et non reconnu se rendent pareil, et sans aucune requête : il n'y a
# rien à demander à un serveur dont le schéma ne porte pas de capacité.
if software in SILENT or software not in READERS:
return NOTHING_ANNOUNCED
caps = READERS[software](
models,
_getter(host, port, http_get),
_poster(host, port, http_post),
)
return _estimate_parameters(caps, models[0] if models else "")
def _addresses(host, resolve):
"""Les adresses de `host`, en chaînes ; vide si rien ne se résout."""
host = _bare_host(host)
if not host:
return []
if _is_address(host):
return [host]
if resolve is None:
resolve = _resolve_addresses
try:
return [str(a) for a in resolve(host) or ()]
except Exception:
# Un résolveur peut lever autre chose qu'une erreur système : celui du
# système lève `OSError`, un résolveur injecté ce qu'il veut. Toutes
# ces issues disent la même chose, que le nom n'est pas résolu.
return []
def _bare_host(host):
"""L'hôte débarrassé de ce qui empêche `ip_address` de le lire.
Les crochets d'une adresse IPv6 littérale et l'identifiant de zone qu'un
résolveur accroche à une adresse de lien local font tous deux lever
`ip_address`, alors que l'adresse elle-même est parfaitement classable.
"""
host = (host or "").strip()
if host.startswith("[") and host.endswith("]"):
host = host[1:-1]
return host.split("%", 1)[0].strip()
def _is_address(text):
try:
ipaddress.ip_address(text)
except ValueError:
return False
return True
def _resolve_addresses(host):
"""Les adresses de `host` selon le résolveur du système."""
return [info[4][0] for info in socket.getaddrinfo(host, None)]
def _rank_of_address(text):
"""Le rang de `text` dans `HOSTING_ORDER`.
La boucle locale est testée AVANT le privé : `ipaddress` rapporte
127.0.0.1 comme privé aussi, et l'ordre inverse classerait la machine même
comme du réseau local. Ce qui n'est ni l'un ni l'autre — une plage de
transition d'opérateur autant qu'une adresse publique — est global : le
rang le plus haut est celui qui ne promet rien.
"""
try:
address = ipaddress.ip_address(_bare_host(text))
except ValueError:
return len(HOSTING_ORDER) - 1
if address.is_loopback:
return 0
if address.is_private:
return 1
return len(HOSTING_ORDER) - 1
def _requirement_order(requires):
"""Les clés à vérifier : l'ensemble fermé, puis ce qu'il ne couvre pas."""
extra = sorted(k for k in requires if k not in REQUIREMENT_KEYS)
return REQUIREMENT_KEYS + tuple(extra)
def _check(key, requires, caps, hosting):
"""Le verdict d'UNE exigence : (verdict, clé de raison).
Deux clés se comportent à part. `hosting` est vérifié même absent, sur son
défaut. Toute autre clé absente n'est pas une exigence et rend « ok », ce
qui distingue « ce gpt ne demande rien là-dessus » de « le serveur ne dit
rien là-dessus », qui rend « unknown ».
"""
if key == "hosting":
return _check_hosting(requires.get(key, DEFAULT_HOSTING), hosting)
if key not in requires:
return "ok", ""
if key == "context_window":
return _check_number(key, requires[key], caps, REASON_CONTEXT)
if key == "parameters":
return _check_number(key, requires[key], caps, REASON_PARAMETERS)
if key == "tool_calling":
return _check_flag(key, requires[key], caps, REASON_TOOLS)
if key == "vision":
return _check_flag(key, requires[key], caps, REASON_VISION)
if key == "json_output":
return _check_flag(key, requires[key], caps, REASON_JSON)
return "unknown", REASON_UNCHECKED_OTHER
def _check_hosting(required, actual):
"""L'échelle d'hébergement, la seule qui grise sans requête.
Un barreau que l'échelle ne connaît pas rend « unknown » : ce n'est pas une
contradiction, et griser un gpt sur une faute de frappe dans son en-tête
serait un refus sans recours.
"""
wanted = _hosting_rank(required)
reached = _hosting_rank(actual)
if wanted is None or reached is None:
return "unknown", REASON_UNCHECKED["hosting"]
if reached > wanted:
return "no", REASON_HOSTING
return "ok", ""
def _hosting_rank(value):
"""Le rang d'un barreau, `any` au plus permissif ; None si inconnu."""
if value == ANY_HOSTING:
return len(HOSTING_ORDER) - 1
if value in HOSTING_ORDER:
return HOSTING_ORDER.index(value)
return None
def _check_number(key, required, caps, reason):
"""Un seuil : satisfait quand la valeur LUE atteint ce qui est demandé."""
try:
wanted = float(required)
except (TypeError, ValueError):
return "unknown", REASON_UNCHECKED[key]
announced = getattr(caps, key)
if announced is None or key in caps.estimated:
return "unknown", REASON_UNCHECKED[key]
if float(announced) < wanted:
return "no", reason
return "ok", ""
def _check_flag(key, required, caps, reason):
"""Un booléen. Un gpt qui n'en a pas besoin est satisfait par tout."""
if not required:
return "ok", ""
announced = getattr(caps, key)
if announced is None or key in caps.estimated:
return "unknown", REASON_UNCHECKED[key]
if not announced:
return "no", reason
return "ok", ""
def _authority(host, port):
"""`hôte:port`, l'hôte entre crochets quand c'est une adresse IPv6."""
host = _bare_host(host)
if ":" in host:
host = f"[{host}]"
return f"{host}:{port}"
def _http(host, port, path, payload=None):
"""`(statut, corps)` d'une requête, ou `None` si elle n'aboutit pas.
N'envoie AUCUN en-tête d'autorisation : un serveur qui exige une clé
n'annonce rien plutôt que de s'en voir présenter une, une clé se
configurant contre un serveur qu'on a nommé. Le corps est lu jusqu'au
plafond, jamais jusqu'à la fin.
"""
url = f"http://{_authority(host, port)}{path}"
data = None
headers = {}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = urllib.request.Request(url, data=data, headers=headers)
try:
with urllib.request.urlopen(request, timeout=BUDGET) as answer:
return answer.status, answer.read(BODY_LIMIT)
except Exception:
# Une socket refusée, un délai dépassé, un statut d'erreur, un corps
# coupé : la lecture n'a rien appris, et le champ reste vide.
return None
def _getter(host, port, http_get):
"""Un lecteur de chemin, qui rend le corps JSON d'un GET ou `None`."""
def fetch(path):
call = http_get if http_get is not None else _http
return _json(call(host, port, path))
return fetch
def _poster(host, port, http_post):
"""Un lecteur de chemin, qui rend le corps JSON d'un POST ou `None`."""
def fetch(path, payload):
if http_post is not None:
return _json(http_post(host, port, path, payload))
return _json(_http(host, port, path, payload))
return fetch
def _json(answer):
"""Le dictionnaire d'une réponse 200, `None` pour tout le reste.
Un statut de démarrage ou un défi d'authentification est un serveur vivant,
mais il n'annonce aucune capacité ; du HTML et un corps tronqué non plus.
"""
if not answer:
return None
status, body = answer
if status != 200:
return None
if isinstance(body, (bytes, bytearray)):
body = bytes(body).decode("utf-8", "replace")
try:
parsed = json.loads(body)
except (TypeError, ValueError):
return None
return parsed if isinstance(parsed, dict) else None
def _integer(value):
"""La valeur en entier positif, `None` si ce n'en est pas un.
Un booléen est écarté avant tout : `isinstance(True, int)` est vrai, et un
drapeau lu comme la longueur 1 serait une capacité inventée. Un zéro ou un
négatif n'annonce rien non plus.
"""
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return int(value) if value > 0 else None
if isinstance(value, str):
try:
return _integer(int(value.strip()))
except ValueError:
return None
return None
def _billions(count):
"""Un nombre de paramètres COMPTÉ, rendu en milliards."""
value = _integer(count)
return None if value is None else round(value / 1e9, 2)
def _billions_from_text(text):
"""Le nombre de milliards écrit dans un texte, `None` s'il n'y en a pas."""
if not isinstance(text, str):
return None
found = PARAMETER_SIZE.search(text)
if not found:
return None
try:
return float(found.group(1).replace(",", "."))
except ValueError:
return None
def _parameter_size(details):
"""Le `parameter_size` d'un bloc de détails, en milliards."""
if not isinstance(details, dict):
return None
return _billions_from_text(details.get("parameter_size"))
def _entries(body, field):
"""La liste de dictionnaires que `body[field]` porte, sinon vide."""
if not isinstance(body, dict):
return []
return [e for e in body.get(field) or () if isinstance(e, dict)]
def _entry_named(entries, name, fields):
"""L'entrée que le serveur publie pour `name`, sinon la première.
Un serveur qui tient plusieurs modèles chargés les publie dans l'ordre
qu'il veut : chercher le nom d'abord évite de lire le contexte du voisin.
"""
for entry in entries:
if name and any(entry.get(field) == name for field in fields):
return entry
return entries[0] if entries else None
def _first_bool(mapping, fields):
"""Le premier de `fields` que `mapping` publie en booléen, sinon `None`."""
if not isinstance(mapping, dict):
return None
for field in fields:
if isinstance(mapping.get(field), bool):
return mapping[field]
return None
def _context_from_model_info(info):
"""La longueur de contexte de `model_info`, `None` si elle n'y est pas.
La clé porte le nom de l'architecture, que `general.architecture` donne.
Le repli sur n'importe quelle clé qui finit par « .context_length » couvre
un serveur qui nomme l'architecture autrement dans les deux champs.
"""
if not isinstance(info, dict):
return None
architecture = info.get("general.architecture")
if isinstance(architecture, str):
found = _integer(info.get(f"{architecture}.context_length"))
if found is not None:
return found
for key, value in info.items():
if isinstance(key, str) and key.endswith(".context_length"):
found = _integer(value)
if found is not None:
return found
return None
def _read_ollama(models, get, post):
"""Ollama, et LocalAI qui émule son API native.
`POST /api/show` porte l'énumération de capacités et le bloc `model_info`
d'où sortent le contexte et le compte de paramètres. `GET /api/tags` est le
repli sur le `parameter_size` que le serveur publie déjà pour le modèle :
c'est une lecture, pas une déduction, donc elle n'est pas estimée.
"""
name = models[0] if models else ""
context = parameters = tools = vision = None
shown = post("/api/show", {"model": name}) if name else None
if isinstance(shown, dict):
announced = shown.get("capabilities")
if isinstance(announced, list):
tools = "tools" in announced
vision = any(mark in announced for mark in VISION_MARKS)
info = shown.get("model_info")
context = _context_from_model_info(info)
if isinstance(info, dict):
parameters = _billions(info.get("general.parameter_count"))
if parameters is None:
parameters = _parameter_size(shown.get("details"))
if parameters is None:
entries = _entries(get("/api/tags"), "models")
entry = _entry_named(entries, name, ("name", "model"))
parameters = _parameter_size(entry.get("details")) if entry else None
return Capabilities(
context_window=context,
parameters=parameters,
tool_calling=tools,
vision=vision,
)
def _read_llamacpp(models, get, post):
"""llama.cpp : les drapeaux dans `/props`, les nombres dans `/v1/models`.
`/props` porte les capacités du gabarit de conversation, `/v1/models` les
nombres du modèle chargé : deux corps pour une seule lecture.
"""
props = get("/props")
tools = _first_bool(
props.get("chat_template_caps") if isinstance(props, dict) else None,
LLAMACPP_TOOL_FLAGS,
)
vision = _first_bool(
props.get("modalities") if isinstance(props, dict) else None,
("vision",),
)
context = parameters = None
entries = _entries(get("/v1/models"), "data")
entry = _entry_named(entries, models[0] if models else "", ("id",))
meta = entry.get("meta") if entry else None
if isinstance(meta, dict):
context = _integer(meta.get("n_ctx_train"))
parameters = _billions(meta.get("n_params"))
return Capabilities(
context_window=context,
parameters=parameters,
tool_calling=tools,
vision=vision,
)
def _read_lmstudio(models, get, post):
"""LM Studio : le contexte et la vision, jamais les outils.
Le serveur ne publie aucun drapeau d'outils. `tool_calling` reste donc
vide, et non `False` : un `False` inventé griserait un serveur qui appelle
des outils en vrai.
"""
entries = _entries(get("/api/v0/models"), "data")
entry = _entry_named(entries, models[0] if models else "", ("id",))
if entry is None:
return NOTHING_ANNOUNCED
kind = entry.get("type")
return Capabilities(
context_window=_integer(entry.get("max_context_length")),
vision=(kind == "vlm") if isinstance(kind, str) else None,
)
def _read_koboldcpp(models, get, post):
"""KoboldCpp : des booléens à l'échelle du serveur, aucun contexte."""
return Capabilities(
vision=_first_bool(get("/api/extra/version"), ("vision",))
)
def _estimate_parameters(caps, name):
"""Le nombre de paramètres deviné dans le NOM du modèle, marqué estimé.
Ne remplit que ce qui n'a pas été lu, et n'existe que pour `parameters` :
un nom de modèle porte souvent sa taille, jamais sa longueur de contexte,
qui reste donc vide plutôt que devinée. Le nom entre dans `estimated`, ce
qui empêche `match` d'en tirer un refus.
"""
if caps.parameters is not None:
return caps
guessed = _billions_from_text(name)
if guessed is None:
return caps
return replace(
caps,
parameters=guessed,
estimated=caps.estimated | {"parameters"},
)
# Un lecteur par famille, tous de la même forme `(models, get, post)`, ce qui
# rend la table lisible comme la liste des serveurs qui annoncent quelque
# chose. Ceux qui n'y sont pas n'annoncent rien.
READERS = {
"ollama": _read_ollama,
"localai": _read_ollama,
"llamacpp": _read_llamacpp,
"lmstudio": _read_lmstudio,
"koboldcpp": _read_koboldcpp,
}
# Reconnus, et muets : leur schéma de modèle ne porte aucun champ de capacité.
# L'API OpenAI distante en fait partie, ce qui est la raison d'être de la règle
# « l'inconnu ne grise jamais » — sinon le catalogue serait vide sur le chemin
# de repli.
SILENT = frozenset(
{
"vllm",
"textgen_webui",
"jan",
"open_webui",
"tabbyapi",
"gpt4all",
"openai",
}
)