"""Provider module registry. 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 - ``plugin.yaml`` — manifest (name, kind: model-provider, version, description) Discovery is lazy: the first call to ``get_provider_profile()`` or ``list_providers()`` scans both locations and imports every plugin. User plugins override bundled plugins on name collision (last-writer-wins), so third parties can monkey-patch or replace any built-in profile without editing the repo. For backward compatibility, ``providers/*.py`` files (other than ``base.py`` and ``__init__.py``) are still discovered via ``pkgutil.iter_modules``. This lets out-of-tree users drop a single-file profile into an editable install without the plugin dir structure. New profiles should prefer the plugin layout. Usage:: from providers import get_provider_profile profile = get_provider_profile("nvidia") # ProviderProfile or None profile = get_provider_profile("kimi") # checks name + aliases """ from __future__ import annotations import importlib import importlib.util import logging import sys from pathlib import Path from providers.base import OMIT_TEMPERATURE, ProviderProfile # noqa: F401 logger = logging.getLogger(__name__) _REGISTRY: dict[str, ProviderProfile] = {} _ALIASES: dict[str, str] = {} _PROVIDER_LIST_CACHE: list[ProviderProfile] | None = None _discovered = False # Repo-root ``plugins/model-providers/`` — populated at discovery time. _BUNDLED_PLUGINS_DIR = ( Path(__file__).resolve().parent.parent / "plugins" / "model-providers" ) def register_provider(profile: ProviderProfile) -> None: """Register a provider profile by name and aliases. Later registrations with the same name replace earlier ones — so user plugins under ``$HERMES_HOME/plugins/model-providers/`` can override bundled profiles without editing repo code. """ global _PROVIDER_LIST_CACHE _REGISTRY[profile.name] = profile for alias in profile.aliases: _ALIASES[alias] = profile.name _PROVIDER_LIST_CACHE = None def get_provider_profile(name: str) -> ProviderProfile | None: """Look up a provider profile by name or alias. Returns None if the provider has no profile (falls back to generic). """ if not _discovered: _discover_providers() canonical = _ALIASES.get(name, name) return _REGISTRY.get(canonical) def list_providers() -> list[ProviderProfile]: """Return all registered provider profiles (one per canonical name).""" global _PROVIDER_LIST_CACHE if not _discovered: _discover_providers() if _PROVIDER_LIST_CACHE is not None: return list(_PROVIDER_LIST_CACHE) # Deduplicate: _REGISTRY has canonical names; _ALIASES points to same objects seen: set[int] = set() result: list[ProviderProfile] = [] for profile in _REGISTRY.values(): pid = id(profile) if pid not in seen: seen.add(pid) result.append(profile) _PROVIDER_LIST_CACHE = result return list(result) def _user_plugins_dir() -> Path | None: """Return ``$HERMES_HOME/plugins/model-providers/`` if it exists.""" try: from hermes_constants import get_hermes_home d = get_hermes_home() / "plugins" / "model-providers" return d if d.is_dir() else None except Exception: return None def _installed_plugins_dir() -> Path | None: """Return ``$HERMES_HOME/plugins/`` if it exists. This is where ``hermes plugins install`` clones a plugin — flat, one directory per plugin, NOT under ``model-providers/``. See :func:`_discover_installed_provider_plugins`. """ try: from hermes_constants import get_hermes_home d = get_hermes_home() / "plugins" return d if d.is_dir() else None except Exception: return None def _declares_model_provider_kind(plugin_dir: Path) -> bool: """Whether ``plugin_dir``'s manifest declares ``kind: model-provider``. Only that kind is imported from the flat install directory — every other plugin there belongs to ``PluginManager``, which owns its lifecycle and consent flow. Parsed with PyYAML when available, falling back to a line scan so provider discovery never hard-depends on it. """ for filename in ("plugin.yaml", "plugin.yml"): manifest = plugin_dir / filename if not manifest.is_file(): continue try: text = manifest.read_text(encoding="utf-8", errors="replace") except Exception: return False try: import yaml data = yaml.safe_load(text) if isinstance(data, dict): return str(data.get("kind", "")).strip() == "model-provider" except Exception: pass for line in text.splitlines(): stripped = line.strip() if stripped.startswith("#") or ":" not in stripped: continue key, _, value = stripped.partition(":") if key.strip() == "kind": return value.strip().strip("\"'") == "model-provider" return False return False def _import_plugin_dir(plugin_dir: Path, source: str) -> None: """Import a single plugin directory so it self-registers. ``source`` is "bundled" or "user", used only for log messages. """ init_file = plugin_dir / "__init__.py" if not init_file.exists(): return # Give bundled plugins a stable import path (``plugins.model_providers.``) # so relative imports within the plugin work. User plugins load via # ``importlib.util.spec_from_file_location`` with a unique module name so # multiple HERMES_HOME profiles don't alias each other. safe_name = plugin_dir.name.replace("-", "_") if source == "bundled": module_name = f"plugins.model_providers.{safe_name}" else: module_name = f"_hermes_user_provider_{safe_name}" if module_name in sys.modules: return # already imported try: spec = importlib.util.spec_from_file_location( module_name, init_file, submodule_search_locations=[str(plugin_dir)] ) if spec is None or spec.loader is None: return module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module spec.loader.exec_module(module) except Exception as exc: logger.warning( "Failed to load %s provider plugin %s: %s", source, plugin_dir.name, exc ) 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. Order: 1. Bundled plugins at ``/plugins/model-providers//`` 2. User plugins at ``$HERMES_HOME/plugins/model-providers//`` 2b. Plugins installed by ``hermes plugins install`` at ``$HERMES_HOME/plugins//`` that declare ``kind: model-provider`` 3. Legacy per-file modules at ``providers/.py`` (back-compat) Each step imports its plugins, which call ``register_provider()`` at module-level. Later steps win on name collision. """ global _discovered if _discovered: 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()): if not child.is_dir() or child.name.startswith(("_", ".")): continue _import_plugin_dir(child, "bundled") # 2. User plugins — under $HERMES_HOME/plugins/model-providers//. # These can override any bundled profile of the same name (last-writer-wins # in register_provider()). user_dir = _user_plugins_dir() if user_dir is not None: for child in sorted(user_dir.iterdir()): if not child.is_dir() or child.name.startswith(("_", ".")): continue _import_plugin_dir(child, "user") # 2b. Plugins installed by ``hermes plugins install`` / the plugin index. # Those clone into $HERMES_HOME/plugins// — flat, NOT under # model-providers/ — so step 2 never sees them. PluginManager does not # import them either: it classifies ``kind: model-provider`` and routes # it here on purpose. Without this step the documented install path # silently half-works — the CLI reports success and the provider does # not exist. Only manifests declaring that kind are imported; every # other plugin in this directory belongs to PluginManager. installed_dir = _installed_plugins_dir() if installed_dir is not None: for child in sorted(installed_dir.iterdir()): if not child.is_dir() or child.name.startswith(("_", ".")): continue if child.name == "model-providers": continue # handled by step 2 if not _declares_model_provider_kind(child): continue _import_plugin_dir(child, "user") # 3. Legacy single-file profiles at providers/.py. Kept for # back-compat — if someone drops a ``providers/foo.py`` into an # editable install, it still works without the plugin layout. try: import pkgutil import providers as _pkg for _importer, modname, _ispkg in pkgutil.iter_modules(_pkg.__path__): if modname.startswith("_") or modname == "base": continue try: importlib.import_module(f"providers.{modname}") except ImportError as exc: logger.warning( "Failed to import legacy provider module %s: %s", modname, exc ) 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.)