Vous avez écrit un plugin provider pour votre propre gateway. Pourquoi Hermes le traite-t-il encore en citoyen de seconde zone ?

Vous avez écrit un plugin provider Hermes pour la gateway d’inférence de votre entreprise. Il apparaît dans hermes model, il répond, vos collègues l’ont installé depuis votre README. Puis les petites coupures commencent : le sélecteur /model n’affiche jamais le badge de raisonnement pour lui ; vous collez une capture d’écran d’un diagramme d’architecture et le modèle répond comme s’il n’y avait pas d’image ; /usage reste éternellement vide, alors vous gardez un onglet de navigateur ouvert à côté ; et un nouveau collègue doit encore copier un token depuis le chat vers .env.
Quatre manques, une seule cause : le core cherchait tout dans le catalogue public models.dev, et votre modèle n’y figure pas. Trois hooks fusionnés le 20 septembre (#116570, #116555, #116576 — tous issus de la vague #116408) rendent trois tâches au plugin : déclarer les capacités, remonter l’usage, et exécuter le login.
1. Capacités du modèle : déclarez une fois, tous les consommateurs vous croient
Commencez par la cause racine. get_model_capabilities résolvait depuis exactement deux endroits — le catalogue models.dev et les model_overrides de l’utilisateur dans config.yaml. Ni l’un ni l’autre ne connaît les modèles d’un provider hors de l’arbre, donc « inconnu » était traité en aval comme « non pris en charge » : le routage d’images décidait text (faisant passer votre capture d’écran en texte), le sélecteur sautait le badge, et la longueur de contexte revenait vide.
Un plugin peut désormais le déclarer sur le profile :
from providers import register_provider
from providers.base import ProviderProfile
register_provider(ProviderProfile(
name="acme",
auth_type="api_key",
env_vars=("ACME_API_KEY",),
base_url="https://api.acme.example/v1",
model_capabilities={
"acme-large-high": {
"supports_reasoning": False, # le palier de raisonnement est fixé par l'id du modèle
"supports_vision": True,
"supports_tools": True,
"context_window": 200000,
"model_family": "acme",
},
"acme-nr": {"supports_reasoning": False},
},
))
Les détails de conception qui comptent :
- Les clés sont des IDs de modèle exacts, et les valeurs sont des entrées partielles du schéma
model_overrides. Les champs omis restent inconnus — ils ne sont pas écrits commeFalse. Une entrée partielle corrige les métadonnées du catalogue pour les champs qu’elle mentionne sans effacer le reste. - Précédence : une entrée explicite
model_overridesde l’utilisateur gagne, puis la déclaration du plugin, puis les données du catalogue ou intégrées. Le plugin fournit la valeur par défaut ; l’utilisateur peut toujours la surcharger dansconfig.yaml(la liste complète des champs figure dans le guide model_overrides). - Il n’y a qu’une seule couture de lecture.
agent/models_dev.py::_apply_overridesfusionne la déclaration, et les badges du sélecteur, la recherche de contexte, le routage d’images et/api/model/infolisent tous par là. Vous déclarez une fois au lieu de deviner quelle surface lit quoi.
Connaissez aussi les limites, pour ne pas en attendre plus que ce que vous obtenez : le badge fast du sélecteur reste une heuristique sur le nom et ne consulte pas cette table, et les paliers de reasoning-effort sont conditionnés par le vocabulaire provider/modèle plutôt que par les capacités.
2. Usage : faites connaître votre provider à /usage
Le deuxième manque, c’est le quota. fetch_account_usage ne distribuait qu’à travers la table de noms fermée _USAGE_FETCHERS — un profile de plugin pouvait se charger et inférer, et pourtant /usage n’avait rien à afficher.
Maintenant le plugin surcharge le hook :
from datetime import datetime, timezone
from agent.account_usage import AccountUsageSnapshot, AccountUsageWindow
def fetch_account_usage(self, *, api_key=None, base_url=None):
return AccountUsageSnapshot(
provider=self.name,
source="my_provider_api",
fetched_at=datetime.now(timezone.utc),
windows=(AccountUsageWindow(label="Monthly", used_percent=39),),
)
Trois règles l’accompagnent : ne formatez pas la sortie vous-même (le rendu est le travail du core), les intégrés restent autoritaires (un plugin du même nom ne déloge jamais openai-codex, anthropic ou openrouter), et l’échec est silencieux (renvoyer None ou lever une exception est interprété comme « aucun usage disponible » et ne casse jamais /usage).
Un seul hook allume quatre surfaces, parce que chacune passe l’id du provider actif directement dans la même fonction au lieu de garder sa propre table :
| Surface | Où vous le voyez |
|---|---|
| CLI | hermes usage --provider acme |
| Commande slash CLI / TUI | /usage (borné à 10 s) |
| Gateway de messagerie | envoi de /usage dans Telegram ou Discord |
| TUI / Desktop | le flux d’usage de session (RPC session.usage) |
Avec un instantané renvoyé, la CLI affiche des lignes comme celles-ci :
📈 Account limits
Provider: acme
Monthly credits: 61% remaining (39% used) • resets in 10d 22h
Extra usage: 0/2,500 credits
Il existe déjà un exemple dans l’arbre : plugins/model-providers/opencode-zen/ lit les fenêtres du plan OpenCode Go via ce hook, sans qu’aucune entrée de table core ait été ajoutée pour lui.
3. Login : OAuth PKCE déclaratif au lieu d’un flux navigateur écrit à la main
Le troisième manque est le plus coûteux à combler à la main : faire supporter OAuth à votre provider signifie généralement écrire vous-même l’ouverture du navigateur, un listener loopback, la validation du callback, la persistance des tokens et le refresh.
Si votre IdP parle le standard OAuth 2.0 Authorization Code + PKCE, vous le déclarez désormais :
from hermes_cli.auth_oauth_pkce_plugin import (
OAuthPKCEConfig, pkce_auth_handler, pkce_refresh_credential,
)
from providers import register_provider
from providers.base import ProviderProfile
cfg = OAuthPKCEConfig(
client_id="hermes-public-client", # client public — PKCE est la preuve, pas de secret
authorize_url="https://auth.example.com/oauth/authorize",
token_url="https://auth.example.com/oauth/token",
scopes=("inference", "offline_access"),
redirect_port=0, # 0 = attribué par l'OS ; figez-le si l'IdP met l'URI sur liste blanche
)
register_provider(ProviderProfile(
name="example-pkce",
auth_type="oauth_external",
base_url="https://api.example.com/v1",
auth_handler=pkce_auth_handler(cfg),
refresh_credential=pkce_refresh_credential(cfg),
))
Le core possède ensuite tout le cycle de vie : hermes auth add example-pkce [--no-browser] ouvre le navigateur (ou affiche l’URL avec une indication de tunnel SSH sur une machine distante), écoute sur http://127.0.0.1:<port>/callback, vérifie le state CSRF, échange le code avec PKCE S256, et stocke le grant comme credential mutualisé (auth_type=oauth, source=manual:loopback_pkce). auth status indique connecté / expiré, auth logout supprime les lignes, et auth refresh ainsi que le chemin de récupération sur 401 font tourner le grant de refresh token — en relisant d’abord auth.json sous le verrou d’auth, pour qu’une rotation déjà effectuée par un autre processus soit adoptée au lieu de dépenser deux fois un refresh token à usage unique.
La frontière de sécurité est appliquée avant toute requête, au login comme au refresh : les deux endpoints doivent être en https:// (le http:// simple n’est accepté que pour un littéral loopback, pour un IdP de développement local) ; l’hôte de token_url doit être celui de authorize_url, un sous-domaine de celui-ci, ou figurer dans allowed_hosts ; le listener se lie au littéral 127.0.0.1 ; et les tokens, le state et le vérificateur PKCE ne sont jamais journalisés.
Puis-je l’utiliser dès aujourd’hui ?
Honnêtement : pas depuis une release. Les trois changements ont été fusionnés le 20 septembre 2026, et la dernière release est v0.21.3 (tag v2026.9.14). Ils sont sur main et dans aucune release, donc hermes update vous donne toujours l’ancien comportement. Si vous voulez la référence, la page website/docs/developer-guide/model-provider-plugin.md sur main porte désormais « Declaring model capabilities », « Account usage » et « Declarative OAuth (PKCE) for plugins ».
Une fois votre plugin écrit, il y a un moyen rapide de voir s’il s’est réellement chargé :
hermes doctor # un profile auth_type="api_key" apparaît sous Provider Connectivity avec une sonde /models
Le schéma
Les trois hooks viennent d’une seule idée : le core fournit la couture, les spécificités du fournisseur vivent dans le plugin. Les capacités sont déclarées (pas des booléens éparpillés), l’usage est un hook qui renvoie un instantané (pas quatre surfaces qui formatent chacune différemment), et le login est une seule config OAuth (pas chaque plugin écrivant son propre flux navigateur). Si vous construisez un plugin provider, deux articles existants s’associent bien à celui-ci : installer des plugins provider via les entry points pip et la dernière fournée de providers (Ramp, Nebius, Tencent) ; pour des plugins prêts à étudier, voir le catalogue de plugins.