hermes-agent/providers/__init__.py
Teknium 9022804d78 feat(providers): make all 33 providers pluggable under plugins/model-providers/
Every provider profile is now a self-contained plugin under
plugins/model-providers/<name>/, mirroring the plugins/platforms/
pattern established for IRC and Teams. The ProviderProfile ABC
stays in providers/; the per-provider profile data moves out.

- plugins/model-providers/<name>/__init__.py calls register_provider()
- plugins/model-providers/<name>/plugin.yaml declares kind: model-provider
- providers/__init__.py._discover_providers() lazily scans bundled plugins
  then $HERMES_HOME/plugins/model-providers/<name>/ (user override path)
- User plugins with the same name override bundled ones (last-writer-wins
  in register_provider)
- Legacy providers/<name>.py layout still supported for back-compat with
  out-of-tree editable installs
- Hermes PluginManager: new kind=model-provider; skipped like memory
  plugins (providers/ discovery owns them); standalone plugins with
  register_provider+ProviderProfile in their __init__.py auto-coerce to
  this kind (same heuristic as memory providers)
- skip_names extended to include 'model-providers' so the general
  PluginManager doesn't double-scan the category
- 4 new tests in tests/providers/test_plugin_discovery.py covering
  bundled discovery, user override, and general-loader isolation
- Docs updated: website/docs/developer-guide/adding-providers.md,
  provider-runtime.md, providers/README.md, plugins/model-providers/README.md

No API break: auth.py / config.py / doctor.py / models.py / runtime_provider.py /
model_metadata.py / auxiliary_client.py / chat_completions.py / run_agent.py
all still consume providers via get_provider_profile() / list_providers() —
they just now see plugin-discovered entries instead of pkgutil-iterated ones.

Third parties can now drop a single directory into
~/.hermes/plugins/model-providers/<name>/ to add or override an inference
provider without touching the repo.
2026-05-05 13:40:01 -07:00

191 lines
6.6 KiB
Python

"""Provider module registry.
Provider profiles can live in two places:
1. Bundled plugins: ``plugins/model-providers/<name>/`` (shipped with hermes-agent)
2. User plugins: ``$HERMES_HOME/plugins/model-providers/<name>/``
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] = {}
_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.
"""
_REGISTRY[profile.name] = profile
for alias in profile.aliases:
_ALIASES[alias] = profile.name
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)."""
if not _discovered:
_discover_providers()
# 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)
return 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 _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.<name>``)
# 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_providers() -> None:
"""Populate the registry by importing every provider plugin.
Order:
1. Bundled plugins at ``<repo>/plugins/model-providers/<name>/``
2. User plugins at ``$HERMES_HOME/plugins/model-providers/<name>/``
3. Legacy per-file modules at ``providers/<name>.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
# 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/<name>/.
# 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")
# 3. Legacy single-file profiles at providers/<name>.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