diff --git a/providers/__init__.py b/providers/__init__.py index 4d828c561d34..011e84afa723 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,128 @@ 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). + + Gating and safety: + + * **Opt-in.** Entry-point plugins are subject to the same + ``plugins.enabled`` allow-list (and ``plugins.disabled`` deny-list) the + general PluginManager enforces — a pip package is never imported just + because it is installed. An entry point whose name is not enabled is + skipped without loading. + * **Provider targets only.** The ``hermes_agent.plugins`` group is shared + with general plugins whose target is ``register(ctx)``. Callables that + require arguments are skipped here (the PluginManager owns them); + provider registration hooks take no arguments by 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 + + # Same opt-in gate as the general PluginManager: only entry points named + # in ``plugins.enabled`` load, and ``plugins.disabled`` always wins. + try: + from hermes_cli.plugins import _get_disabled_plugins, _get_enabled_plugins + + enabled = _get_enabled_plugins() # None = nothing enabled yet (opt-in default) + disabled = _get_disabled_plugins() + except Exception: # pragma: no cover — config layer unavailable + enabled, disabled = None, set() + if not enabled: + 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: + if ep.name not in enabled or ep.name in disabled: + logger.debug( + "entry-point provider %r skipped: not enabled in config", ep.name + ) + continue + 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 + # AND zero-arg: general plugins in this shared group expose + # ``register(ctx)`` (requires an argument) and belong to the + # PluginManager, not the provider registry. + if callable(loaded): + if _requires_arguments(loaded): + logger.debug( + "entry-point %r skipped by provider scan: target requires " + "arguments (general plugin owned by PluginManager)", + ep.name, + ) + continue + try: + loaded() + except Exception as exc: + logger.warning( + "Entry-point provider plugin %r raised on invocation: %s", + ep.name, + exc, + ) + + +def _requires_arguments(fn) -> bool: + """True when ``fn`` cannot be called with zero arguments. + + Used to distinguish provider registration hooks (zero-arg by contract) + from general plugin hooks (``register(ctx)``) sharing the same entry-point + group. Unintrospectable callables (C extensions) are treated as zero-arg + and left to the per-entry exception guard. + """ + import inspect + + try: + sig = inspect.signature(fn) + except (TypeError, ValueError): # pragma: no cover — builtins/C callables + return False + for param in sig.parameters.values(): + if param.kind in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ) and param.default is inspect.Parameter.empty: + return True + return False + + def _discover_providers() -> None: """Populate the registry by importing every provider plugin. @@ -160,6 +284,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 +336,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 000000000000..86965f47fb34 --- /dev/null +++ b/tests/providers/test_entry_point_discovery.py @@ -0,0 +1,225 @@ +"""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() + + +def _enable(monkeypatch, *names, disabled=()): + """Gate helper: mark entry-point names enabled/disabled in config. + + ``_discover_entry_point_providers`` enforces the PluginManager's + ``plugins.enabled`` opt-in allow-list, so tests must enable their fake + entry points explicitly. + """ + import hermes_cli.plugins as hp + + monkeypatch.setattr(hp, "_get_enabled_plugins", lambda: set(names)) + monkeypatch.setattr(hp, "_get_disabled_plugins", lambda: set(disabled)) + + +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) + _enable(monkeypatch, "ep-callable", "ep-module") + _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_not_enabled_is_skipped(monkeypatch): + """Entry points honor the plugins.enabled opt-in gate — installed ≠ loaded.""" + fake_eps = _FakeEntryPoints([_FakeEP("ep-callable", _register_via_callable)]) + import importlib.metadata as md + + monkeypatch.setattr(md, "entry_points", lambda: fake_eps) + _enable(monkeypatch, "some-other-plugin") # ep-callable NOT enabled + _clear_provider_caches() + try: + assert providers.get_provider_profile("ep-callable") is None + finally: + _clear_provider_caches() + + +def test_entry_point_disabled_wins_over_enabled(monkeypatch): + """plugins.disabled is a deny-list that beats plugins.enabled.""" + fake_eps = _FakeEntryPoints([_FakeEP("ep-callable", _register_via_callable)]) + import importlib.metadata as md + + monkeypatch.setattr(md, "entry_points", lambda: fake_eps) + _enable(monkeypatch, "ep-callable", disabled=("ep-callable",)) + _clear_provider_caches() + try: + assert providers.get_provider_profile("ep-callable") is None + finally: + _clear_provider_caches() + + +def test_general_plugin_register_ctx_not_invoked(monkeypatch): + """A register(ctx)-style general plugin sharing the group is never called.""" + calls = [] + + def _general_plugin_target(): + def register(ctx): # requires an argument — PluginManager contract + calls.append(ctx) + + return register + + fake_eps = _FakeEntryPoints([_FakeEP("general-plugin", _general_plugin_target)]) + import importlib.metadata as md + + monkeypatch.setattr(md, "entry_points", lambda: fake_eps) + _enable(monkeypatch, "general-plugin") + _clear_provider_caches() + try: + providers._discover_providers() + assert calls == [] # never invoked (would have been a TypeError anyway) + 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) + _enable(monkeypatch, "broken", "ep-callable") + _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 are discovered FIRST (lowest precedence): last-writer-wins + in register_provider() means a bundled/user profile of the same name + overrides a pip impostor.""" + 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) + _enable(monkeypatch, "openrouter") # enabled, so precedence is what's tested + _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 dd1be9f291b7..5127107fa312 100644 --- a/website/docs/developer-guide/model-provider-plugin.md +++ b/website/docs/developer-guide/model-provider-plugin.md @@ -248,14 +248,49 @@ 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` never invokes provider registration for pip packages (its +entry-point path targets `register(ctx)`-style general plugins, gated by +`plugins.enabled`), so the provider registry does its own scan. Two rules +apply: + +- **Opt-in required.** The same `plugins.enabled` allow-list (and + `plugins.disabled` deny-list) from `config.yaml` governs this scan. A pip + package is never imported just because it is installed — users must add the + entry-point name to `plugins.enabled`: + + ```yaml + plugins: + enabled: + - acme-inference + ``` + +- **Lowest precedence.** Entry-point plugins are discovered **before** + filesystem plugins: 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. + +Targets that require arguments (a general plugin's `register(ctx)`) are +skipped by the provider scan — they belong to the `PluginManager`. 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.