mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
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.
191 lines
6.6 KiB
Python
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
|