feat(providers): discover pip-installed model providers via entry points
Model-provider discovery was filesystem-only (bundled dir, $HERMES_HOME, legacy providers/*.py). The general PluginManager scans the hermes_agent.plugins entry-point group but deliberately does NOT import kind=model-provider manifests (providers/ owns their lifecycle), so a pip-installed provider was recorded yet never called register_provider() — it never appeared in the picker, contradicting the 'Distribute via pip' docs. Add a _discover_entry_point_providers() step that scans the hermes_agent.plugins group and imports each entry, supporting both a module:func callable target and a bare self-registering module target. - Runs BEFORE filesystem plugins (lowest precedence): last-writer-wins means bundled/$HERMES_HOME profiles always override a pip provider of the same name, so a third-party package cannot hijack a first-party provider id. - Per-entry failures are isolated (logged + skipped), so one broken package can't break discovery. - Docs updated to describe the real mechanism; tests cover callable + module targets, failure isolation, and first-party precedence.
This commit is contained in:
+82
-1
@@ -1,9 +1,11 @@
|
||||
"""Provider module registry.
|
||||
|
||||
Provider profiles can live in two places:
|
||||
Provider profiles can live in three places:
|
||||
|
||||
1. Bundled plugins: ``plugins/model-providers/<name>/`` (shipped with hermes-agent)
|
||||
2. User plugins: ``$HERMES_HOME/plugins/model-providers/<name>/``
|
||||
3. Pip-installed plugins: distributions exposing a ``hermes_agent.plugins``
|
||||
entry point (``module:func`` callable or a self-registering ``module``)
|
||||
|
||||
Each plugin directory contains:
|
||||
- ``__init__.py`` — calls ``register_provider(profile)`` at import
|
||||
@@ -144,6 +146,65 @@ def _import_plugin_dir(plugin_dir: Path, source: str) -> None:
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
|
||||
def _discover_entry_point_providers() -> None:
|
||||
"""Import pip-installed provider plugins via the ``hermes_agent.plugins``
|
||||
entry-point group so they self-register.
|
||||
|
||||
A distribution ships::
|
||||
|
||||
[project.entry-points."hermes_agent.plugins"]
|
||||
acme-inference = "acme_hermes_plugin:register"
|
||||
|
||||
The target may be either a **callable** (``module:func`` — invoked with no
|
||||
args; typically calls ``register_provider(profile)``) or a **module**
|
||||
(``module`` — imported for its module-level ``register_provider`` side
|
||||
effect, mirroring the directory-plugin ``__init__.py`` contract).
|
||||
|
||||
Failures are swallowed per-entry (a broken third-party package must not
|
||||
break provider discovery) and logged at warning level. This scan runs
|
||||
first, so filesystem plugins (bundled + ``$HERMES_HOME``) keep their
|
||||
documented override precedence via last-writer-wins in
|
||||
``register_provider()`` — a pip package cannot hijack a first-party
|
||||
provider name.
|
||||
"""
|
||||
try:
|
||||
import importlib.metadata as _md
|
||||
except Exception: # pragma: no cover — importlib.metadata always present ≥3.8
|
||||
return
|
||||
|
||||
group = "hermes_agent.plugins"
|
||||
try:
|
||||
eps = _md.entry_points()
|
||||
# Python 3.10+ exposes .select(); older returns a dict-like mapping.
|
||||
if hasattr(eps, "select"):
|
||||
group_eps = list(eps.select(group=group))
|
||||
else: # pragma: no cover — legacy interpreters
|
||||
group_eps = list(eps.get(group, [])) # type: ignore[attr-defined]
|
||||
except Exception as exc:
|
||||
logger.debug("entry-point provider scan skipped: %s", exc)
|
||||
return
|
||||
|
||||
for ep in group_eps:
|
||||
try:
|
||||
loaded = ep.load()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to load entry-point provider plugin %r: %s", ep.name, exc
|
||||
)
|
||||
continue
|
||||
# ``module:func`` → callable we invoke; bare ``module`` → import side
|
||||
# effect already happened during load(). Only call when it's callable.
|
||||
if callable(loaded):
|
||||
try:
|
||||
loaded()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Entry-point provider plugin %r raised on invocation: %s",
|
||||
ep.name,
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
def _discover_providers() -> None:
|
||||
"""Populate the registry by importing every provider plugin.
|
||||
|
||||
@@ -160,6 +221,22 @@ def _discover_providers() -> None:
|
||||
return
|
||||
_discovered = True
|
||||
|
||||
# 0. Pip-installed plugins — entry points in the ``hermes_agent.plugins``
|
||||
# group (the same group the general PluginManager uses). The manager
|
||||
# records model-provider manifests for introspection but deliberately
|
||||
# does NOT import them — provider lifecycle is owned here — so without
|
||||
# this step a ``pip install``ed provider never calls
|
||||
# ``register_provider()`` and is never selectable.
|
||||
#
|
||||
# Discovered FIRST, i.e. lowest precedence: because
|
||||
# ``register_provider()`` is last-writer-wins, running this before the
|
||||
# filesystem steps means a bundled or ``$HERMES_HOME`` profile of the
|
||||
# same name always overrides a pip-installed one. That prevents a
|
||||
# third-party package from silently hijacking a first-party provider
|
||||
# name (e.g. ``openrouter``) while still letting pip packages add
|
||||
# genuinely new providers.
|
||||
_discover_entry_point_providers()
|
||||
|
||||
# 1. Bundled plugins — shipped with hermes-agent.
|
||||
if _BUNDLED_PLUGINS_DIR.is_dir():
|
||||
for child in sorted(_BUNDLED_PLUGINS_DIR.iterdir()):
|
||||
@@ -196,3 +273,7 @@ def _discover_providers() -> None:
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# (Pip entry-point providers are discovered in step 0, before the
|
||||
# filesystem plugins, so first-party profiles always win on name
|
||||
# collision — see _discover_entry_point_providers.)
|
||||
|
||||
Reference in New Issue
Block a user