LLM Providers
Responsabilidad
Traducir «nombre de provider (nvidia, kimi, openai, openrouter…)» a «hostname concreto, preprocesamiento de mensajes, extra_body, lista de modelos». Hermes no usa herencia para abstraer los providers, sino un dataclass ProviderProfile + un conjunto de hooks; el profile se registra como plugin, logrando «declarar = integrar». Soporta Nous Portal, OpenRouter, OpenAI y endpoints personalizados.
Motivo de diseño
¿Por qué un dataclass ProviderProfile + hooks en lugar de class OpenAIProvider(BaseProvider)? Hermes integra 30+ providers; en la inmensa mayoría, la diferencia es solo base_url, el header de auth y un par de campos especiales. Una jerarquía obliga a cada subclase a sobrescribir un montón de métodos y el 90% acaba con overrides vacíos. El dataclass + hooks convierte «declarar 7 campos + sobrescribir 1 hook» en toda la integración; la lógica más retorcida (p. ej. dónde van los campos de reasoning en Kimi) se resuelve reescribiendo el hook.
Los profiles van en plugins/model-providers/<name>/ y no en providers/<name>.py: el directorio de plugin trae un manifiesto plugin.yaml y submódulos, y lo puede sobrescribir el user con la misma estructura en $HERMES_HOME/plugins/ (last-writer-wins) sin necesidad de forkear el repo para parchear un provider interno. Los archivos sueltos de providers/*.py son un vestigio de back-compat.
Archivos clave
OMIT_TEMPERATURE:21— objeto centinela que indica «no enviar temperature» (Kimi deja que el server lo gestione):@dataclass ProviderProfile:38-88— metadatos del provider (name, aliases, base_url, auth, política de 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 es triestado: None usa el default del caller; OMIT_TEMPERATURE no envía el campo; un valor concreto lo sobrescribe. supports_vision_tool_messages por defecto es True; el MiMo de Xiaomi acepta user messages multimodales pero rechaza tool content en formato list, así que hay que ponerlo a False, si no 400 "text is not set".
get_hostname / prepare_messages:98-118— hooks: a qué host va la petición, cómo se preprocesan los mensajesbuild_extra_body:119-175— hook: campos del body específicos del provider:
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 por defecto es un dict vacío; las subclases lo sobrescriben bajo demanda. build_api_kwargs_extras reparte el campo en dos rutas — extra_body y api_kwargs top-level —: en OpenRouter, reasoning va en extra_body; en Kimi, reasoning_effort es top-level.
fetch_models:175-232— hook: lista de modelos disponibles para ese provider._profile_user_agent()devuelvehermes-cli/<version>en lugar dePython-urllib/<ver>; algunos providers llevan WAF por delante que devuelven 403 al UA por defecto.doc del módulo:9-28— describe el mecanismo de descubrimiento perezoso (escanea plugins solo en la primera llamada)register_provider:53-65— registra un profile:
_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)Descubrimiento perezoso: la primera llamada a get_provider_profile dispara _discover_providers(), que escanea tres sitios — bundled, user plugins y archivos sueltos legacy. La tabla de alias hace que nombres sinónimos como kimi / moonshot apunten al mismo profile.
get_provider_profile:65-76— consulta por name o aliaslist_providers:76-91— lista todos los profilesdirectorio plugins/model-providers/— definiciones reales de profiles (un subdirectorio por provider)
Flujo de datos
init_agent:276recibe el nombre del provider configurado por el usuario.- Llama a
get_provider_profile:65(lo que dispara el descubrimiento perezoso la primera vez: escaneaplugins/model-providers/<name>/, lo importa y llama aregister_provider). - Una vez obtenido el
ProviderProfile:get_hostname(providers/base.py:98) fija el host de la peticiónprepare_messages(providers/base.py:111) preprocesa losapi_messagesproducidos porbuild_turn_context:268build_extra_body(providers/base.py:119) añade campos específicos del provider- la temperature sigue la política del profile (None / OMIT_TEMPERATURE / valor concreto)
- El bucle principal usa la petición ensamblada para llamar a la API del provider (
subbucle de reintentos:1227). - El
extra_bodyde un provider personalizado se fusiona en_merge_custom_provider_extra_body:257.
Límites y fallos
- El profile no existe: si la config lleva un nombre de provider desconocido,
get_provider_profiledevuelveNone; el bucle principal cae a un provider genérico de chat_completions y el picker, al no tenerfallback_models, muestra una lista vacía. - Sin soporte de visión: con
supports_vision=False, las imágenes en lostool_resultno se meten tal cual en el tool message; consupports_vision_tool_messages=False, usar content tipo list devuelve 400 — hay que degradar la imagen o convertirla a base64. fetch_modelsfalla: el endpoint/modelsde algunos providers lleva WAF (el UAPython-urllibrecibe 403).fetch_modelsusa el UAhermes-cli/<version>para esquivarlo; si aun así falla, cae afallback_models— aquí solo deberían figurar modelos agentic, los que no soportan tool-calling provocan que el picker elija mal.- Plugin de user pisa bundled:
register_provideres last-writer-wins; un plugin de user con el mismo nombre que uno bundled lo sobrescribe. Losproviders/<name>.pylegacy, al importarse los últimos, también pueden pisar — footgun; el código nuevo va por el directorio de plugins.
Resumen
La abstracción de provider se resume en «profile + hooks», no «herencia». Añadir un provider solo requiere crear un subdirectorio en plugins/model-providers/, declarar un ProviderProfile y llamar a register_provider, sin tocar la base. El descubrimiento perezoso mantiene el arranque rápido y la carga bajo demanda.