Escribiste un plugin de provider para tu propio gateway. ¿Por qué Hermes lo sigue tratando como un ciudadano de segunda?


Escribiste un plugin de provider de Hermes para el gateway de inferencia de tu empresa. Aparece en hermes model, responde, y tus compañeros lo instalaron siguiendo tu README. Y entonces empiezan los cortes de papel: el selector de /model nunca muestra la insignia de razonamiento; pegas una captura de un diagrama de arquitectura y el modelo contesta como si no hubiera imagen; /usage está permanentemente en blanco, así que mantienes una pestaña del navegador abierta al lado; y un compañero recién llegado todavía tiene que copiar un token del chat a .env.

Cuatro carencias, una causa: el núcleo lo buscaba todo en el catálogo público de models.dev, y tu modelo no está en ese catálogo. Tres hooks fusionados el 20 de septiembre (#116570, #116555, #116576 — todos parte de la oleada #116408) devuelven al plugin tres trabajos: declarar capacidades, reportar uso y ejecutar el login.

1. Capacidades del modelo: declara una vez y todos los consumidores te creen

Empecemos por la causa raíz. get_model_capabilities resolvía desde exactamente dos sitios: el catálogo de models.dev y los model_overrides del usuario en config.yaml. Ninguno conoce los modelos de un provider fuera del árbol, así que «desconocido» se trataba aguas abajo como «no soportado»: el enrutado de imágenes decidía text (reenviando tu captura como texto), el selector se saltaba la insignia y la longitud de contexto volvía vacía.

A partir de ahora un plugin puede declararlo en el perfil:

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,   # el nivel de razonamiento lo fija el id del modelo
            "supports_vision": True,
            "supports_tools": True,
            "context_window": 200000,
            "model_family": "acme",
        },
        "acme-nr": {"supports_reasoning": False},
    },
))

Los detalles de diseño que importan:

  • Las claves son IDs de modelo exactos, y los valores son entradas parciales del esquema model_overrides. Los campos omitidos siguen siendo desconocidos: no se escriben como False. Una entrada parcial parchea los metadatos del catálogo solo en los campos que menciona, sin borrar el resto.
  • Precedencia: gana una entrada explícita de model_overrides del usuario, luego la declaración del plugin, después los datos del catálogo o integrados. El plugin aporta el valor por defecto; el usuario siempre puede sobrescribirlo en config.yaml (la lista completa de campos está en la guía de model_overrides).
  • Hay exactamente una costura de lectura. agent/models_dev.py::_apply_overrides fusiona la declaración, y las insignias del selector, la búsqueda de contexto, el enrutado de imágenes y /api/model/info leen todos por ahí. Declaras una vez en lugar de adivinar qué superficie lee qué.

Conviene conocer también los límites, para no esperar más de lo que hay: la insignia fast del selector sigue siendo una heurística sobre el nombre y no consulta esta tabla, y los niveles de esfuerzo de razonamiento los controla el vocabulario de provider/modelo, no las capacidades.

2. Uso: haz que /usage conozca tu provider

La segunda carencia son las cuotas. fetch_account_usage solo despachaba a través de la tabla cerrada de nombres _USAGE_FETCHERS: un perfil de plugin podía cargarse e inferir, pero /usage no tenía nada que renderizar.

Ahora el plugin sobrescribe el 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),),
    )

Tres reglas vienen con él: no formatees la salida tú mismo (renderizar es trabajo del núcleo), los integrados siguen siendo la autoridad (un plugin con el mismo nombre nunca desplaza a openai-codex, anthropic ni openrouter) y el fallo es silencioso (devolver None o lanzar una excepción se trata como «no hay uso disponible» y nunca rompe /usage).

Un solo hook ilumina cuatro superficies, porque cada una pasa el id del provider activo directamente a la misma función en lugar de mantener su propia tabla:

Superficie Dónde lo ves
CLI hermes usage --provider acme
Comando slash de CLI / TUI /usage (acotado a 10 s)
Gateway de mensajería enviar /usage en Telegram o Discord
TUI / Desktop el feed de uso de sesión (RPC session.usage)

Con una instantánea devuelta, la CLI renderiza líneas como estas:

📈 Account limits
Provider: acme
Monthly credits: 61% remaining (39% used) • resets in 10d 22h
Extra usage: 0/2,500 credits

Ya hay un ejemplo dentro del árbol: plugins/model-providers/opencode-zen/ lee las ventanas del plan OpenCode Go a través de este hook, sin añadir ninguna entrada a la tabla del núcleo.

3. Login: OAuth PKCE declarativo en lugar de un flujo de navegador escrito a mano

La tercera carencia es la más cara de cerrar a mano: hacer que tu provider soporte OAuth suele implicar escribir tú mismo la apertura del navegador, el listener de loopback, la validación del callback, la persistencia del token y el refresh.

Si tu IdP habla OAuth 2.0 Authorization Code + PKCE estándar, ahora lo declaras:

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",     # cliente público: PKCE es la prueba, no hay 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 = asignado por el SO; fíjalo si el IdP tiene esa URI en allowlist
)

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),
))

A partir de ahí el núcleo es dueño de todo el ciclo de vida: hermes auth add example-pkce [--no-browser] abre el navegador (o imprime la URL con una pista para túnel SSH en una máquina remota), escucha en http://127.0.0.1:<port>/callback, comprueba el state de CSRF, intercambia el código con PKCE S256 y guarda la concesión como credencial agrupada (auth_type=oauth, source=manual:loopback_pkce). auth status informa de sesión iniciada o caducada, auth logout elimina las filas, y auth refresh junto con la ruta de recuperación de 401 rotan mediante la concesión de refresh token: antes releen auth.json bajo el lock de auth, de modo que adoptan una rotación que otro proceso ya hizo en lugar de gastar dos veces un refresh token de un solo uso.

El límite de seguridad se aplica antes de cualquier petición, tanto en el login como en el refresh: ambos endpoints deben ser https:// (el http:// plano solo se acepta para un literal de loopback, pensado para un IdP de desarrollo local); el host de token_url debe ser el host de authorize_url, un subdominio suyo o estar en allowed_hosts; el listener se ata al literal 127.0.0.1; y los tokens, el state y el verificador PKCE nunca acaban en los logs.

¿Puedo usar esto hoy?

Con sinceridad: no desde una release. Los tres cambios se fusionaron el 20 de septiembre de 2026, y la release más reciente es la v0.21.3 (tag v2026.9.14). Están en main y en ninguna release, así que hermes update te sigue dando el comportamiento antiguo. Si quieres la referencia, la página website/docs/developer-guide/model-provider-plugin.md en main ya incluye «Declaring model capabilities», «Account usage» y «Declarative OAuth (PKCE) for plugins».

Cuando tengas el plugin escrito, hay una forma rápida de ver si se cargó de verdad:

hermes doctor     # un perfil con auth_type="api_key" aparece en Provider Connectivity con un sondeo /models

El patrón

Los tres hooks salen de una misma idea: el núcleo pone la costura, los detalles del proveedor viven en el plugin. Las capacidades se declaran (no son booleanos dispersos), el uso es un hook que devuelve una instantánea (no cuatro superficies formateando cada una a su manera) y el login es una única configuración de OAuth (no cada plugin escribiendo su propio flujo de navegador). Si estás construyendo un plugin de provider, hay dos artículos que encajan bien con este: instalar plugins de provider mediante entry points de pip y el lote más reciente de providers (Ramp, Nebius, Tencent); para ver plugins ya hechos que estudiar, consulta el catálogo de plugins.