LLM Providers
Responsabilité
Traduire « nom de provider (nvidia, kimi, openai, openrouter…) » en « hostname concret, prétraitement des messages, extra_body, liste de modèles ». Hermes n'utilise pas une hiérarchie d'héritage pour abstraire les providers, mais une dataclass ProviderProfile + un ensemble de hooks ; le profil est enregistré comme plugin (plugin), ce qui se traduit par « déclarer c'est câbler ». Prend en charge Nous Portal, OpenRouter, OpenAI et endpoints personnalisés.
Mot de conception
Pourquoi une dataclass ProviderProfile + des hooks plutôt que class OpenAIProvider(BaseProvider) ? Hermes intègre plus de 30 providers, et dans la grande majorité des cas la différence se résume à base_url, un header d'auth et quelques champs spéciaux. Une hiérarchie d'héritage forcerait chaque sous-classe à overrider un paquet de méthodes, dont 90 % ne seraient que des implémentations vides. Dataclass + hooks permet « déclarer 7 champs + overrider 1 hook » pour boucler l'intégration ; les logiques plus complexes (comme la position du champ reasoning chez Kimi) se rabattent sur une réécriture du hook.
Le profil vit dans plugins/model-providers/<name>/ plutôt que dans providers/<name>.py : le répertoire de plugin embarque un manifeste plugin.yaml et des sous-modules, il est surchargé à l'identique par le $HERMES_HOME/plugins/ utilisateur (last-writer-wins), ce qui permet de patcher un provider intégré sans forker le dépôt. Les fichiers uniques providers/*.py sont un legacy de rétro-compatibilité.
Fichiers clés
OMIT_TEMPERATURE:21— objet sentinelle signifiant « ne pas envoyer temperature » (Kimi laisse le serveur gérer) :@dataclass ProviderProfile:38-88— métadonnées du provider (name, aliases, base_url, auth, stratégie temperature…) :
OMIT_TEMPERATURE = object()
@dataclass
class ProviderProfile:
name: str
api_mode: str = "chat_completions"
aliases: tuple = ()
env_vars: tuple = ()
base_url: str = ""
auth_type: str = "api_key"
supports_vision: bool = False
supports_vision_tool_messages: bool = True
fallback_models: tuple = ()
fixed_temperature: Any = Nonefixed_temperature est tri-state : None utilise la valeur par défaut du caller, OMIT_TEMPERATURE n'envoie pas le champ, une valeur concrète surcharge. supports_vision_tool_messages vaut True par défaut ; le MiMo de Xiaomi accepte les user messages multimodaux mais rejette le contenu de type list dans les tool messages, il faut le mettre à False, sinon 400 « text is not set ».
get_hostname / prepare_messages:98-118— hooks : quel hôte pour la requête, comment prétraiter les messagesbuild_extra_body:119-175— hook : champs propres au provider dans le corps de requête :
def build_extra_body(self, *, session_id: str | None = None, **context: Any) -> dict[str, Any]:
"""Merged into the API kwargs extra_body. Default: empty dict."""
return {}
def build_api_kwargs_extras(self, *, reasoning_config=None, **context) -> tuple[dict, dict]:
"""Returns (extra_body_additions, top_level_kwargs).
OpenRouter: reasoning in extra_body. Kimi: reasoning_effort is top-level."""
return {}, {}build_extra_body renvoie un dict vide par défaut, les sous-classes overrident au besoin. build_api_kwargs_extras éclate les champs en deux canaux, extra_body et api_kwargs top-level : pour OpenRouter reasoning va dans extra_body, pour Kimi reasoning_effort est top-level.
fetch_models:175-232— hook : récupère la liste des modèles disponibles du provider._profile_user_agent()renvoiehermes-cli/<version>plutôt quePython-urllib/<ver>, certains providers ont un WAF devant qui 403 l'UA par défaut.doc module:9-28— décrit le mécanisme de découverte paresseuse (scan des plugins au premier appel)register_provider:53-65— enregistre un profil :
_REGISTRY: dict[str, ProviderProfile] = {}
_ALIASES: dict[str, str] = {}
def register_provider(profile: ProviderProfile) -> None:
"""Later registrations with the same name replace earlier ones —
user plugins can override bundled profiles without editing repo code."""
_REGISTRY[profile.name] = profile
for alias in profile.aliases:
_ALIASES[alias] = profile.name
def get_provider_profile(name: str) -> ProviderProfile | None:
if not _discovered:
_discover_providers()
canonical = _ALIASES.get(name, name)
return _REGISTRY.get(canonical)Découverte paresseuse : c'est le premier get_provider_profile qui déclenche _discover_providers(), qui scanne trois endroits — bundled, plugins utilisateur et fichiers uniques legacy. La table d'alias fait que kimi / moonshot et autres synonymes pointent vers le même profil.
get_provider_profile:65-76— requête par nom ou aliaslist_providers:76-91— liste tous les profilsrépertoire plugins/model-providers/— définitions réelles des profils (un sous-répertoire par provider)
Flux de données
init_agent:276récupère le nom de provider configuré par l'utilisateur.- Appelle
get_provider_profile:65(premier appel déclenche la découverte paresseuse : scan deplugins/model-providers/<name>/, import etregister_provider). - Après obtention du
ProviderProfile:get_hostname(providers/base.py:98) fixe l'hôte de requêteprepare_messages(providers/base.py:111) prétraite lesapi_messagesproduits parbuild_turn_context:268build_extra_body(providers/base.py:119) ajoute les champs propres au provider- temperature suit la stratégie du profil (None / OMIT_TEMPERATURE / valeur concrète)
- La boucle principale appelle l'API du provider avec la requête assemblée (
sous-boucle de retry:1227). - L'extra_body des providers personnalisés est fusionné dans
_merge_custom_provider_extra_body:257.
Limites et échecs
- Profil inexistant : la config déclare un nom de provider inconnu,
get_provider_profilerenvoieNone, la boucle principale retombe sur un provider génériquechat_completions, et le picker ne trouve aucunfallback_models— affichage vide. - Vision non supportée : quand
supports_vision=False, les images des tool_results ne sont pas collées telles quelles dans le tool message ; quandsupports_vision_tool_messages=False, envoyer un contenu de type list 400, il faut dégrader l'image ou la convertir en base64. - Échec de
fetch_models: le endpoint/modelsde certains providers est derrière un WAF (l'UAPython-urllibpar défaut se prend un 403).fetch_modelsutilise l'UAhermes-cli/<version>pour contourner, et retombe surfallback_modelsen cas d'échec — seuls les modèles agentic devraient figurer là, sinon le picker se trompe. - Plugin utilisateur surchargeant le bundled :
register_providerest last-writer-wins, le plugin utilisateur écrase le bundled homonyme importé après. Lesproviders/<name>.pylegacy, importés en dernier, peuvent aussi surcharger — un footgun, le nouveau code doit passer par le répertoire de plugins.
Résumé
Les mots-clés de l'abstraction provider sont « profil + hooks » plutôt qu'« héritage ». Ajouter un provider consiste à créer un sous-répertoire dans plugins/model-providers/, déclarer un ProviderProfile et appeler register_provider, sans toucher à la base. La découverte paresseuse garantit un démarrage rapide et un chargement à la demande.