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 comme False. 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_overrides de 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 dans config.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_overrides fusionne la déclaration, et les badges du sélecteur, la recherche de contexte, le routage d’images et /api/model/info lisent 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.