diff --git a/providers/__init__.py b/providers/__init__.py index 4d828c561d..b34bc4f61c 100644 --- a/providers/__init__.py +++ b/providers/__init__.py @@ -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//`` (shipped with hermes-agent) 2. User plugins: ``$HERMES_HOME/plugins/model-providers//`` +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.) diff --git a/tests/providers/test_entry_point_discovery.py b/tests/providers/test_entry_point_discovery.py new file mode 100644 index 0000000000..b82e748cfa --- /dev/null +++ b/tests/providers/test_entry_point_discovery.py @@ -0,0 +1,156 @@ +"""Tests for pip entry-point provider discovery (hermes_agent.plugins group). + +Verifies that ``providers/__init__.py`` imports provider plugins exposed via a +distribution's ``hermes_agent.plugins`` entry point, supporting both a +``module:func`` callable target and a bare self-registering ``module`` target. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +import providers + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _clear_provider_caches(): + providers._REGISTRY.clear() + providers._ALIASES.clear() + providers._PROVIDER_LIST_CACHE = None + providers._discovered = False + for mod in list(sys.modules.keys()): + if mod.startswith("plugins.model_providers") or mod.startswith( + "_hermes_user_provider" + ): + del sys.modules[mod] + + +@pytest.fixture(autouse=True) +def _restore_real_discovery(): + """Snapshot registry state; on teardown re-run REAL discovery. + + These tests monkeypatch ``importlib.metadata.entry_points`` and evict the + ``plugins.model_providers`` submodules to force re-discovery. Without an + explicit restore, the emptied registry / ``sys.modules`` would leak into + later tests (e.g. ``from plugins.model_providers.custom import ...``). + + This fixture is autouse and declared before ``monkeypatch`` is requested, + so it tears down LAST — after ``entry_points`` is restored to the real + implementation — letting the final ``_discover_providers()`` repopulate + both the registry and ``sys.modules`` from the real filesystem plugins. + """ + yield + _clear_provider_caches() + providers._discover_providers() + + + +class _FakeEP: + def __init__(self, name, loader): + self.name = name + self.group = "hermes_agent.plugins" + self._loader = loader + + def load(self): + return self._loader() + + +class _FakeEntryPoints: + def __init__(self, eps): + self._eps = eps + + def select(self, group): + return [e for e in self._eps if e.group == group] + + +def _register_via_callable(): + from providers.base import ProviderProfile + + def register(): + providers.register_provider( + ProviderProfile(name="ep-callable", aliases=("epc",), base_url="https://a.test/v1") + ) + + return register # ep.load() returns the callable; discovery invokes it + + +def _register_via_module(): + # ep.load() returns a non-callable object; the import side effect already + # registered the profile (mirrors a bare ``module`` target). + from providers.base import ProviderProfile + + providers.register_provider( + ProviderProfile(name="ep-module", base_url="https://b.test/v1") + ) + return object() # non-callable → discovery must NOT try to call it + + +def test_entry_point_callable_and_module_targets(monkeypatch): + fake_eps = _FakeEntryPoints( + [ + _FakeEP("ep-callable", _register_via_callable), + _FakeEP("ep-module", _register_via_module), + ] + ) + import importlib.metadata as md + + monkeypatch.setattr(md, "entry_points", lambda: fake_eps) + _clear_provider_caches() + try: + assert providers.get_provider_profile("ep-callable") is not None + assert providers.get_provider_profile("epc") is not None # alias + assert providers.get_provider_profile("ep-module") is not None + finally: + _clear_provider_caches() + + +def test_entry_point_failure_is_isolated(monkeypatch): + def _boom(): + raise RuntimeError("broken plugin") + + fake_eps = _FakeEntryPoints( + [ + _FakeEP("broken", _boom), + _FakeEP("ep-callable", _register_via_callable), + ] + ) + import importlib.metadata as md + + monkeypatch.setattr(md, "entry_points", lambda: fake_eps) + _clear_provider_caches() + try: + # A broken entry point must not prevent the good one from registering. + assert providers.get_provider_profile("ep-callable") is not None + finally: + _clear_provider_caches() + + +def test_filesystem_plugins_win_over_entry_points(monkeypatch): + """Entry points scan last, so a bundled/user profile of the same name wins.""" + from providers.base import ProviderProfile + + def _register_ep_openrouter(): + def register(): + providers.register_provider( + ProviderProfile(name="openrouter", base_url="https://impostor.test/v1") + ) + + return register + + fake_eps = _FakeEntryPoints([_FakeEP("openrouter", _register_ep_openrouter)]) + import importlib.metadata as md + + monkeypatch.setattr(md, "entry_points", lambda: fake_eps) + _clear_provider_caches() + try: + p = providers.get_provider_profile("openrouter") + assert p is not None + # The bundled OpenRouter profile (real base_url) must win, not the impostor. + assert "impostor.test" not in (p.base_url or "") + finally: + _clear_provider_caches() diff --git a/website/docs/developer-guide/model-provider-plugin.md b/website/docs/developer-guide/model-provider-plugin.md index dd1be9f291..ea48f80b66 100644 --- a/website/docs/developer-guide/model-provider-plugin.md +++ b/website/docs/developer-guide/model-provider-plugin.md @@ -248,14 +248,32 @@ The general `PluginManager` (the thing `hermes plugins` operates on) **sees** mo ## Distribute via pip -Like any Hermes plugin, model providers can ship as a pip package. Add an entry point to your `pyproject.toml`: +Model providers can ship as a pip package. Expose an entry point in the +`hermes_agent.plugins` group in your `pyproject.toml`: ```toml [project.entry-points."hermes_agent.plugins"] acme-inference = "acme_hermes_plugin:register" ``` -…where `acme_hermes_plugin:register` is a function that calls `register_provider(profile)`. The general PluginManager picks up entry-point plugins during `discover_and_load()`. For `kind: model-provider` pip plugins, you still need to declare the kind in your manifest (or rely on the source-text heuristic). +The target may be either: + +- a **callable** (`module:func`) — invoked with no arguments; it should call + `register_provider(profile)`, or +- a **bare module** (`module`) — imported for its module-level + `register_provider(...)` side effect, mirroring the directory-plugin + `__init__.py` contract. + +`providers/__init__.py` discovers these entry points itself (the general +`PluginManager` records model-provider manifests but never imports them, so it +cannot register the profile). Entry-point plugins are discovered **before** +filesystem plugins, giving them the lowest precedence: because +`register_provider()` is last-writer-wins, a bundled or `$HERMES_HOME` profile +of the same name always overrides a pip-installed one. A pip package can add a +genuinely new provider, but cannot silently hijack a first-party provider name. + +A broken entry point is isolated — it is logged at warning level and skipped, +and never blocks discovery of the other providers. See [Building a Hermes Plugin](/developer-guide/plugins#distribute-via-pip) for the full entry-points setup.