From 9022804d78e88253d138d448e9107a3884b2b96c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 5 May 2026 13:36:08 -0700 Subject: [PATCH] feat(providers): make all 33 providers pluggable under plugins/model-providers/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every provider profile is now a self-contained plugin under plugins/model-providers//, 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//__init__.py calls register_provider() - plugins/model-providers//plugin.yaml declares kind: model-provider - providers/__init__.py._discover_providers() lazily scans bundled plugins then $HERMES_HOME/plugins/model-providers// (user override path) - User plugins with the same name override bundled ones (last-writer-wins in register_provider) - Legacy providers/.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// to add or override an inference provider without touching the repo. --- hermes_cli/plugins.py | 40 ++- plugins/model-providers/README.md | 70 ++++ .../model-providers/ai-gateway/__init__.py | 0 .../model-providers/ai-gateway/plugin.yaml | 5 + .../alibaba-coding-plan/__init__.py | 0 .../alibaba-coding-plan/plugin.yaml | 5 + .../model-providers/alibaba/__init__.py | 0 plugins/model-providers/alibaba/plugin.yaml | 5 + .../model-providers/anthropic/__init__.py | 0 plugins/model-providers/anthropic/plugin.yaml | 5 + .../model-providers/arcee/__init__.py | 0 plugins/model-providers/arcee/plugin.yaml | 5 + .../model-providers/azure-foundry/__init__.py | 0 .../model-providers/azure-foundry/plugin.yaml | 5 + .../model-providers/bedrock/__init__.py | 0 plugins/model-providers/bedrock/plugin.yaml | 5 + .../model-providers/copilot-acp/__init__.py | 0 .../model-providers/copilot-acp/plugin.yaml | 5 + .../model-providers/copilot/__init__.py | 0 plugins/model-providers/copilot/plugin.yaml | 5 + .../model-providers/custom/__init__.py | 0 plugins/model-providers/custom/plugin.yaml | 5 + .../model-providers/deepseek/__init__.py | 0 plugins/model-providers/deepseek/plugin.yaml | 5 + .../model-providers/gemini/__init__.py | 0 plugins/model-providers/gemini/plugin.yaml | 5 + .../model-providers/gmi/__init__.py | 0 plugins/model-providers/gmi/plugin.yaml | 5 + .../model-providers/huggingface/__init__.py | 0 .../model-providers/huggingface/plugin.yaml | 5 + .../model-providers/kilocode/__init__.py | 0 plugins/model-providers/kilocode/plugin.yaml | 5 + .../model-providers/kimi-coding/__init__.py | 0 .../model-providers/kimi-coding/plugin.yaml | 5 + .../model-providers/minimax/__init__.py | 0 plugins/model-providers/minimax/plugin.yaml | 5 + .../model-providers/nous/__init__.py | 0 plugins/model-providers/nous/plugin.yaml | 5 + .../model-providers/nvidia/__init__.py | 0 plugins/model-providers/nvidia/plugin.yaml | 5 + .../model-providers/ollama-cloud/__init__.py | 0 .../model-providers/ollama-cloud/plugin.yaml | 5 + .../model-providers/openai-codex/__init__.py | 0 .../model-providers/openai-codex/plugin.yaml | 5 + .../model-providers/opencode-zen/__init__.py | 0 .../model-providers/opencode-zen/plugin.yaml | 5 + .../model-providers/openrouter/__init__.py | 0 .../model-providers/openrouter/plugin.yaml | 5 + .../model-providers/qwen-oauth/__init__.py | 0 .../model-providers/qwen-oauth/plugin.yaml | 5 + .../model-providers/stepfun/__init__.py | 0 plugins/model-providers/stepfun/plugin.yaml | 5 + .../model-providers/xai/__init__.py | 0 plugins/model-providers/xai/plugin.yaml | 5 + .../model-providers/xiaomi/__init__.py | 0 plugins/model-providers/xiaomi/plugin.yaml | 5 + .../model-providers/zai/__init__.py | 0 plugins/model-providers/zai/plugin.yaml | 5 + providers/README.md | 327 +++--------------- providers/__init__.py | 155 +++++++-- tests/providers/test_plugin_discovery.py | 145 ++++++++ .../docs/developer-guide/adding-providers.md | 12 +- .../docs/developer-guide/provider-runtime.md | 5 +- 63 files changed, 585 insertions(+), 309 deletions(-) create mode 100644 plugins/model-providers/README.md rename providers/vercel.py => plugins/model-providers/ai-gateway/__init__.py (100%) create mode 100644 plugins/model-providers/ai-gateway/plugin.yaml rename providers/alibaba_coding_plan.py => plugins/model-providers/alibaba-coding-plan/__init__.py (100%) create mode 100644 plugins/model-providers/alibaba-coding-plan/plugin.yaml rename providers/alibaba.py => plugins/model-providers/alibaba/__init__.py (100%) create mode 100644 plugins/model-providers/alibaba/plugin.yaml rename providers/anthropic.py => plugins/model-providers/anthropic/__init__.py (100%) create mode 100644 plugins/model-providers/anthropic/plugin.yaml rename providers/arcee.py => plugins/model-providers/arcee/__init__.py (100%) create mode 100644 plugins/model-providers/arcee/plugin.yaml rename providers/azure_foundry.py => plugins/model-providers/azure-foundry/__init__.py (100%) create mode 100644 plugins/model-providers/azure-foundry/plugin.yaml rename providers/bedrock.py => plugins/model-providers/bedrock/__init__.py (100%) create mode 100644 plugins/model-providers/bedrock/plugin.yaml rename providers/copilot_acp.py => plugins/model-providers/copilot-acp/__init__.py (100%) create mode 100644 plugins/model-providers/copilot-acp/plugin.yaml rename providers/copilot.py => plugins/model-providers/copilot/__init__.py (100%) create mode 100644 plugins/model-providers/copilot/plugin.yaml rename providers/custom.py => plugins/model-providers/custom/__init__.py (100%) create mode 100644 plugins/model-providers/custom/plugin.yaml rename providers/deepseek.py => plugins/model-providers/deepseek/__init__.py (100%) create mode 100644 plugins/model-providers/deepseek/plugin.yaml rename providers/gemini.py => plugins/model-providers/gemini/__init__.py (100%) create mode 100644 plugins/model-providers/gemini/plugin.yaml rename providers/gmi.py => plugins/model-providers/gmi/__init__.py (100%) create mode 100644 plugins/model-providers/gmi/plugin.yaml rename providers/huggingface.py => plugins/model-providers/huggingface/__init__.py (100%) create mode 100644 plugins/model-providers/huggingface/plugin.yaml rename providers/kilocode.py => plugins/model-providers/kilocode/__init__.py (100%) create mode 100644 plugins/model-providers/kilocode/plugin.yaml rename providers/kimi.py => plugins/model-providers/kimi-coding/__init__.py (100%) create mode 100644 plugins/model-providers/kimi-coding/plugin.yaml rename providers/minimax.py => plugins/model-providers/minimax/__init__.py (100%) create mode 100644 plugins/model-providers/minimax/plugin.yaml rename providers/nous.py => plugins/model-providers/nous/__init__.py (100%) create mode 100644 plugins/model-providers/nous/plugin.yaml rename providers/nvidia.py => plugins/model-providers/nvidia/__init__.py (100%) create mode 100644 plugins/model-providers/nvidia/plugin.yaml rename providers/ollama_cloud.py => plugins/model-providers/ollama-cloud/__init__.py (100%) create mode 100644 plugins/model-providers/ollama-cloud/plugin.yaml rename providers/openai_codex.py => plugins/model-providers/openai-codex/__init__.py (100%) create mode 100644 plugins/model-providers/openai-codex/plugin.yaml rename providers/opencode.py => plugins/model-providers/opencode-zen/__init__.py (100%) create mode 100644 plugins/model-providers/opencode-zen/plugin.yaml rename providers/openrouter.py => plugins/model-providers/openrouter/__init__.py (100%) create mode 100644 plugins/model-providers/openrouter/plugin.yaml rename providers/qwen.py => plugins/model-providers/qwen-oauth/__init__.py (100%) create mode 100644 plugins/model-providers/qwen-oauth/plugin.yaml rename providers/stepfun.py => plugins/model-providers/stepfun/__init__.py (100%) create mode 100644 plugins/model-providers/stepfun/plugin.yaml rename providers/xai.py => plugins/model-providers/xai/__init__.py (100%) create mode 100644 plugins/model-providers/xai/plugin.yaml rename providers/xiaomi.py => plugins/model-providers/xiaomi/__init__.py (100%) create mode 100644 plugins/model-providers/xiaomi/plugin.yaml rename providers/zai.py => plugins/model-providers/zai/__init__.py (100%) create mode 100644 plugins/model-providers/zai/plugin.yaml create mode 100644 tests/providers/test_plugin_discovery.py diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index e921034699..5b30e7e7ca 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -173,7 +173,7 @@ def _get_enabled_plugins() -> Optional[set]: # Data classes # --------------------------------------------------------------------------- -_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform"} +_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform", "model-provider"} @dataclass @@ -643,15 +643,17 @@ class PluginManager: # - flat: ``plugins/disk-cleanup/plugin.yaml`` (standalone) # - category: ``plugins/image_gen/openai/plugin.yaml`` (backend) # - # ``memory/`` and ``context_engine/`` are skipped at the top level — - # they have their own discovery systems. ``platforms/`` is a category - # holding platform adapters (scanned one level deeper below). + # ``memory/``, ``context_engine/``, and ``model-providers/`` are + # skipped at the top level — they have their own discovery systems + # (plugins/memory/__init__.py, providers/__init__.py). ``platforms/`` + # is a category holding platform adapters (scanned one level deeper + # below). repo_plugins = get_bundled_plugins_dir() manifests.extend( self._scan_directory( repo_plugins, source="bundled", - skip_names={"memory", "context_engine", "platforms"}, + skip_names={"memory", "context_engine", "platforms", "model-providers"}, ) ) manifests.extend( @@ -709,6 +711,21 @@ class PluginManager: ) continue + # Model provider plugins are loaded by providers/__init__.py + # (its own lazy discovery keyed off first get_provider_profile() + # call). We record the manifest here for introspection but do + # not import the module — a second import would create two + # ProviderProfile instances and break the "last writer wins" + # override semantics between bundled and user plugins. + if manifest.kind == "model-provider": + loaded = LoadedPlugin(manifest=manifest, enabled=True) + self._plugins[lookup_key] = loaded + logger.debug( + "Skipping '%s' (model-provider, handled by providers/ discovery)", + lookup_key, + ) + continue + # Built-in backends auto-load — they ship with hermes and must # just work. Selection among them (e.g. which image_gen backend # services calls) is driven by ``.provider`` config, @@ -886,6 +903,19 @@ class PluginManager: "treating as kind='exclusive'", key, ) + elif ( + "register_provider" in source_text + and "ProviderProfile" in source_text + ): + # Model provider plugin (calls register_provider() + # from ``providers`` with a ProviderProfile). Route + # to providers/__init__.py discovery. + kind = "model-provider" + logger.debug( + "Plugin %s: detected model provider, " + "treating as kind='model-provider'", + key, + ) except Exception: pass diff --git a/plugins/model-providers/README.md b/plugins/model-providers/README.md new file mode 100644 index 0000000000..d1d1025f47 --- /dev/null +++ b/plugins/model-providers/README.md @@ -0,0 +1,70 @@ +# Model Provider Plugins + +Each subdirectory is a self-contained provider profile plugin. The +directory layout mirrors `plugins/platforms/`: + +``` +plugins/model-providers/ +├── openrouter/ +│ ├── __init__.py # registers the ProviderProfile +│ └── plugin.yaml # manifest: name, kind, version, description +├── anthropic/ +│ ├── __init__.py +│ └── plugin.yaml +└── ... +``` + +## How discovery works + +`providers/__init__.py._discover_providers()` scans this directory (and +`$HERMES_HOME/plugins/model-providers/`) the first time anything calls +`get_provider_profile()` or `list_providers()`. Each `__init__.py` is +imported and expected to call `providers.register_provider(profile)`. + +User plugins at `$HERMES_HOME/plugins/model-providers//` override +bundled plugins of the same name — last-writer-wins in +`register_provider()`. Drop a file there to replace a built-in. + +## Adding a new provider + +1. Create `plugins/model-providers//__init__.py`: + + ```python + from providers import register_provider + from providers.base import ProviderProfile + + my_provider = ProviderProfile( + name="your-provider", + aliases=("alias1", "alias2"), + display_name="Your Provider", + description="One-line description shown in the setup picker", + signup_url="https://your-provider.example.com/keys", + env_vars=("YOUR_PROVIDER_API_KEY", "YOUR_PROVIDER_BASE_URL"), + base_url="https://api.your-provider.example.com/v1", + default_aux_model="your-cheap-model", + ) + + register_provider(my_provider) + ``` + +2. Create `plugins/model-providers//plugin.yaml`: + + ```yaml + name: your-provider-profile + kind: model-provider + version: 1.0.0 + description: Short sentence about the provider + author: Your Name + ``` + +Nothing else needs to change. `auth.py`, `config.py`, `models.py`, +`doctor.py`, `model_metadata.py`, `runtime_provider.py`, and the +chat_completions transport all auto-wire from the registry. + +## Non-trivial profiles + +Override the `ProviderProfile` hooks in a subclass for per-provider +quirks — see `plugins/model-providers/openrouter/__init__.py` for +`build_extra_body` and `build_api_kwargs_extras` examples, and +`plugins/model-providers/gemini/__init__.py` for `thinking_config` +translation. diff --git a/providers/vercel.py b/plugins/model-providers/ai-gateway/__init__.py similarity index 100% rename from providers/vercel.py rename to plugins/model-providers/ai-gateway/__init__.py diff --git a/plugins/model-providers/ai-gateway/plugin.yaml b/plugins/model-providers/ai-gateway/plugin.yaml new file mode 100644 index 0000000000..252ca42ed6 --- /dev/null +++ b/plugins/model-providers/ai-gateway/plugin.yaml @@ -0,0 +1,5 @@ +name: ai-gateway-provider +kind: model-provider +version: 1.0.0 +description: Vercel AI Gateway +author: Nous Research diff --git a/providers/alibaba_coding_plan.py b/plugins/model-providers/alibaba-coding-plan/__init__.py similarity index 100% rename from providers/alibaba_coding_plan.py rename to plugins/model-providers/alibaba-coding-plan/__init__.py diff --git a/plugins/model-providers/alibaba-coding-plan/plugin.yaml b/plugins/model-providers/alibaba-coding-plan/plugin.yaml new file mode 100644 index 0000000000..a158f23d99 --- /dev/null +++ b/plugins/model-providers/alibaba-coding-plan/plugin.yaml @@ -0,0 +1,5 @@ +name: alibaba-coding-plan-provider +kind: model-provider +version: 1.0.0 +description: Alibaba Cloud Coding Plan +author: Nous Research diff --git a/providers/alibaba.py b/plugins/model-providers/alibaba/__init__.py similarity index 100% rename from providers/alibaba.py rename to plugins/model-providers/alibaba/__init__.py diff --git a/plugins/model-providers/alibaba/plugin.yaml b/plugins/model-providers/alibaba/plugin.yaml new file mode 100644 index 0000000000..08fcf50bf1 --- /dev/null +++ b/plugins/model-providers/alibaba/plugin.yaml @@ -0,0 +1,5 @@ +name: alibaba-provider +kind: model-provider +version: 1.0.0 +description: Alibaba DashScope (international) +author: Nous Research diff --git a/providers/anthropic.py b/plugins/model-providers/anthropic/__init__.py similarity index 100% rename from providers/anthropic.py rename to plugins/model-providers/anthropic/__init__.py diff --git a/plugins/model-providers/anthropic/plugin.yaml b/plugins/model-providers/anthropic/plugin.yaml new file mode 100644 index 0000000000..7770a5ce85 --- /dev/null +++ b/plugins/model-providers/anthropic/plugin.yaml @@ -0,0 +1,5 @@ +name: anthropic-provider +kind: model-provider +version: 1.0.0 +description: Anthropic (Claude) +author: Nous Research diff --git a/providers/arcee.py b/plugins/model-providers/arcee/__init__.py similarity index 100% rename from providers/arcee.py rename to plugins/model-providers/arcee/__init__.py diff --git a/plugins/model-providers/arcee/plugin.yaml b/plugins/model-providers/arcee/plugin.yaml new file mode 100644 index 0000000000..8a12c52033 --- /dev/null +++ b/plugins/model-providers/arcee/plugin.yaml @@ -0,0 +1,5 @@ +name: arcee-provider +kind: model-provider +version: 1.0.0 +description: Arcee AI +author: Nous Research diff --git a/providers/azure_foundry.py b/plugins/model-providers/azure-foundry/__init__.py similarity index 100% rename from providers/azure_foundry.py rename to plugins/model-providers/azure-foundry/__init__.py diff --git a/plugins/model-providers/azure-foundry/plugin.yaml b/plugins/model-providers/azure-foundry/plugin.yaml new file mode 100644 index 0000000000..791f82b75a --- /dev/null +++ b/plugins/model-providers/azure-foundry/plugin.yaml @@ -0,0 +1,5 @@ +name: azure-foundry-provider +kind: model-provider +version: 1.0.0 +description: Azure AI Foundry +author: Nous Research diff --git a/providers/bedrock.py b/plugins/model-providers/bedrock/__init__.py similarity index 100% rename from providers/bedrock.py rename to plugins/model-providers/bedrock/__init__.py diff --git a/plugins/model-providers/bedrock/plugin.yaml b/plugins/model-providers/bedrock/plugin.yaml new file mode 100644 index 0000000000..8516f29e41 --- /dev/null +++ b/plugins/model-providers/bedrock/plugin.yaml @@ -0,0 +1,5 @@ +name: bedrock-provider +kind: model-provider +version: 1.0.0 +description: AWS Bedrock +author: Nous Research diff --git a/providers/copilot_acp.py b/plugins/model-providers/copilot-acp/__init__.py similarity index 100% rename from providers/copilot_acp.py rename to plugins/model-providers/copilot-acp/__init__.py diff --git a/plugins/model-providers/copilot-acp/plugin.yaml b/plugins/model-providers/copilot-acp/plugin.yaml new file mode 100644 index 0000000000..bb3d7ace5a --- /dev/null +++ b/plugins/model-providers/copilot-acp/plugin.yaml @@ -0,0 +1,5 @@ +name: copilot-acp-provider +kind: model-provider +version: 1.0.0 +description: GitHub Copilot via ACP subprocess +author: Nous Research diff --git a/providers/copilot.py b/plugins/model-providers/copilot/__init__.py similarity index 100% rename from providers/copilot.py rename to plugins/model-providers/copilot/__init__.py diff --git a/plugins/model-providers/copilot/plugin.yaml b/plugins/model-providers/copilot/plugin.yaml new file mode 100644 index 0000000000..cdaa8f5495 --- /dev/null +++ b/plugins/model-providers/copilot/plugin.yaml @@ -0,0 +1,5 @@ +name: copilot-provider +kind: model-provider +version: 1.0.0 +description: GitHub Copilot +author: Nous Research diff --git a/providers/custom.py b/plugins/model-providers/custom/__init__.py similarity index 100% rename from providers/custom.py rename to plugins/model-providers/custom/__init__.py diff --git a/plugins/model-providers/custom/plugin.yaml b/plugins/model-providers/custom/plugin.yaml new file mode 100644 index 0000000000..9784ee2028 --- /dev/null +++ b/plugins/model-providers/custom/plugin.yaml @@ -0,0 +1,5 @@ +name: custom-provider +kind: model-provider +version: 1.0.0 +description: Custom / Ollama / local OpenAI-compatible endpoint +author: Nous Research diff --git a/providers/deepseek.py b/plugins/model-providers/deepseek/__init__.py similarity index 100% rename from providers/deepseek.py rename to plugins/model-providers/deepseek/__init__.py diff --git a/plugins/model-providers/deepseek/plugin.yaml b/plugins/model-providers/deepseek/plugin.yaml new file mode 100644 index 0000000000..0a33565f80 --- /dev/null +++ b/plugins/model-providers/deepseek/plugin.yaml @@ -0,0 +1,5 @@ +name: deepseek-provider +kind: model-provider +version: 1.0.0 +description: DeepSeek +author: Nous Research diff --git a/providers/gemini.py b/plugins/model-providers/gemini/__init__.py similarity index 100% rename from providers/gemini.py rename to plugins/model-providers/gemini/__init__.py diff --git a/plugins/model-providers/gemini/plugin.yaml b/plugins/model-providers/gemini/plugin.yaml new file mode 100644 index 0000000000..cd586b0886 --- /dev/null +++ b/plugins/model-providers/gemini/plugin.yaml @@ -0,0 +1,5 @@ +name: gemini-provider +kind: model-provider +version: 1.0.0 +description: Google Gemini (API key + Cloud Code OAuth) +author: Nous Research diff --git a/providers/gmi.py b/plugins/model-providers/gmi/__init__.py similarity index 100% rename from providers/gmi.py rename to plugins/model-providers/gmi/__init__.py diff --git a/plugins/model-providers/gmi/plugin.yaml b/plugins/model-providers/gmi/plugin.yaml new file mode 100644 index 0000000000..95f61a48a0 --- /dev/null +++ b/plugins/model-providers/gmi/plugin.yaml @@ -0,0 +1,5 @@ +name: gmi-provider +kind: model-provider +version: 1.0.0 +description: GMI Cloud +author: Nous Research diff --git a/providers/huggingface.py b/plugins/model-providers/huggingface/__init__.py similarity index 100% rename from providers/huggingface.py rename to plugins/model-providers/huggingface/__init__.py diff --git a/plugins/model-providers/huggingface/plugin.yaml b/plugins/model-providers/huggingface/plugin.yaml new file mode 100644 index 0000000000..006368718b --- /dev/null +++ b/plugins/model-providers/huggingface/plugin.yaml @@ -0,0 +1,5 @@ +name: huggingface-provider +kind: model-provider +version: 1.0.0 +description: HuggingFace Inference Providers +author: Nous Research diff --git a/providers/kilocode.py b/plugins/model-providers/kilocode/__init__.py similarity index 100% rename from providers/kilocode.py rename to plugins/model-providers/kilocode/__init__.py diff --git a/plugins/model-providers/kilocode/plugin.yaml b/plugins/model-providers/kilocode/plugin.yaml new file mode 100644 index 0000000000..96ea65440a --- /dev/null +++ b/plugins/model-providers/kilocode/plugin.yaml @@ -0,0 +1,5 @@ +name: kilocode-provider +kind: model-provider +version: 1.0.0 +description: Kilo Code +author: Nous Research diff --git a/providers/kimi.py b/plugins/model-providers/kimi-coding/__init__.py similarity index 100% rename from providers/kimi.py rename to plugins/model-providers/kimi-coding/__init__.py diff --git a/plugins/model-providers/kimi-coding/plugin.yaml b/plugins/model-providers/kimi-coding/plugin.yaml new file mode 100644 index 0000000000..c9f00d87b6 --- /dev/null +++ b/plugins/model-providers/kimi-coding/plugin.yaml @@ -0,0 +1,5 @@ +name: kimi-coding-provider +kind: model-provider +version: 1.0.0 +description: Moonshot Kimi Coding (global + China) +author: Nous Research diff --git a/providers/minimax.py b/plugins/model-providers/minimax/__init__.py similarity index 100% rename from providers/minimax.py rename to plugins/model-providers/minimax/__init__.py diff --git a/plugins/model-providers/minimax/plugin.yaml b/plugins/model-providers/minimax/plugin.yaml new file mode 100644 index 0000000000..131eb7de16 --- /dev/null +++ b/plugins/model-providers/minimax/plugin.yaml @@ -0,0 +1,5 @@ +name: minimax-provider +kind: model-provider +version: 1.0.0 +description: MiniMax M-series (global + China + OAuth) +author: Nous Research diff --git a/providers/nous.py b/plugins/model-providers/nous/__init__.py similarity index 100% rename from providers/nous.py rename to plugins/model-providers/nous/__init__.py diff --git a/plugins/model-providers/nous/plugin.yaml b/plugins/model-providers/nous/plugin.yaml new file mode 100644 index 0000000000..6ec234b6ee --- /dev/null +++ b/plugins/model-providers/nous/plugin.yaml @@ -0,0 +1,5 @@ +name: nous-provider +kind: model-provider +version: 1.0.0 +description: Nous Research Portal +author: Nous Research diff --git a/providers/nvidia.py b/plugins/model-providers/nvidia/__init__.py similarity index 100% rename from providers/nvidia.py rename to plugins/model-providers/nvidia/__init__.py diff --git a/plugins/model-providers/nvidia/plugin.yaml b/plugins/model-providers/nvidia/plugin.yaml new file mode 100644 index 0000000000..dd548034cc --- /dev/null +++ b/plugins/model-providers/nvidia/plugin.yaml @@ -0,0 +1,5 @@ +name: nvidia-provider +kind: model-provider +version: 1.0.0 +description: NVIDIA NIM +author: Nous Research diff --git a/providers/ollama_cloud.py b/plugins/model-providers/ollama-cloud/__init__.py similarity index 100% rename from providers/ollama_cloud.py rename to plugins/model-providers/ollama-cloud/__init__.py diff --git a/plugins/model-providers/ollama-cloud/plugin.yaml b/plugins/model-providers/ollama-cloud/plugin.yaml new file mode 100644 index 0000000000..a0ebed67a9 --- /dev/null +++ b/plugins/model-providers/ollama-cloud/plugin.yaml @@ -0,0 +1,5 @@ +name: ollama-cloud-provider +kind: model-provider +version: 1.0.0 +description: Ollama Cloud +author: Nous Research diff --git a/providers/openai_codex.py b/plugins/model-providers/openai-codex/__init__.py similarity index 100% rename from providers/openai_codex.py rename to plugins/model-providers/openai-codex/__init__.py diff --git a/plugins/model-providers/openai-codex/plugin.yaml b/plugins/model-providers/openai-codex/plugin.yaml new file mode 100644 index 0000000000..f397cd4f6f --- /dev/null +++ b/plugins/model-providers/openai-codex/plugin.yaml @@ -0,0 +1,5 @@ +name: openai-codex-provider +kind: model-provider +version: 1.0.0 +description: OpenAI Codex (Responses API) +author: Nous Research diff --git a/providers/opencode.py b/plugins/model-providers/opencode-zen/__init__.py similarity index 100% rename from providers/opencode.py rename to plugins/model-providers/opencode-zen/__init__.py diff --git a/plugins/model-providers/opencode-zen/plugin.yaml b/plugins/model-providers/opencode-zen/plugin.yaml new file mode 100644 index 0000000000..23a3c90da1 --- /dev/null +++ b/plugins/model-providers/opencode-zen/plugin.yaml @@ -0,0 +1,5 @@ +name: opencode-zen-provider +kind: model-provider +version: 1.0.0 +description: OpenCode (Zen + Go) +author: Nous Research diff --git a/providers/openrouter.py b/plugins/model-providers/openrouter/__init__.py similarity index 100% rename from providers/openrouter.py rename to plugins/model-providers/openrouter/__init__.py diff --git a/plugins/model-providers/openrouter/plugin.yaml b/plugins/model-providers/openrouter/plugin.yaml new file mode 100644 index 0000000000..e278aadaee --- /dev/null +++ b/plugins/model-providers/openrouter/plugin.yaml @@ -0,0 +1,5 @@ +name: openrouter-provider +kind: model-provider +version: 1.0.0 +description: OpenRouter aggregator +author: Nous Research diff --git a/providers/qwen.py b/plugins/model-providers/qwen-oauth/__init__.py similarity index 100% rename from providers/qwen.py rename to plugins/model-providers/qwen-oauth/__init__.py diff --git a/plugins/model-providers/qwen-oauth/plugin.yaml b/plugins/model-providers/qwen-oauth/plugin.yaml new file mode 100644 index 0000000000..2cecc002fe --- /dev/null +++ b/plugins/model-providers/qwen-oauth/plugin.yaml @@ -0,0 +1,5 @@ +name: qwen-oauth-provider +kind: model-provider +version: 1.0.0 +description: Qwen Portal (OAuth) +author: Nous Research diff --git a/providers/stepfun.py b/plugins/model-providers/stepfun/__init__.py similarity index 100% rename from providers/stepfun.py rename to plugins/model-providers/stepfun/__init__.py diff --git a/plugins/model-providers/stepfun/plugin.yaml b/plugins/model-providers/stepfun/plugin.yaml new file mode 100644 index 0000000000..36d3e36f01 --- /dev/null +++ b/plugins/model-providers/stepfun/plugin.yaml @@ -0,0 +1,5 @@ +name: stepfun-provider +kind: model-provider +version: 1.0.0 +description: StepFun Step Plan +author: Nous Research diff --git a/providers/xai.py b/plugins/model-providers/xai/__init__.py similarity index 100% rename from providers/xai.py rename to plugins/model-providers/xai/__init__.py diff --git a/plugins/model-providers/xai/plugin.yaml b/plugins/model-providers/xai/plugin.yaml new file mode 100644 index 0000000000..10e884e8a1 --- /dev/null +++ b/plugins/model-providers/xai/plugin.yaml @@ -0,0 +1,5 @@ +name: xai-provider +kind: model-provider +version: 1.0.0 +description: xAI Grok (Responses API) +author: Nous Research diff --git a/providers/xiaomi.py b/plugins/model-providers/xiaomi/__init__.py similarity index 100% rename from providers/xiaomi.py rename to plugins/model-providers/xiaomi/__init__.py diff --git a/plugins/model-providers/xiaomi/plugin.yaml b/plugins/model-providers/xiaomi/plugin.yaml new file mode 100644 index 0000000000..e422fb7013 --- /dev/null +++ b/plugins/model-providers/xiaomi/plugin.yaml @@ -0,0 +1,5 @@ +name: xiaomi-provider +kind: model-provider +version: 1.0.0 +description: Xiaomi MiMo +author: Nous Research diff --git a/providers/zai.py b/plugins/model-providers/zai/__init__.py similarity index 100% rename from providers/zai.py rename to plugins/model-providers/zai/__init__.py diff --git a/plugins/model-providers/zai/plugin.yaml b/plugins/model-providers/zai/plugin.yaml new file mode 100644 index 0000000000..a7bf3736eb --- /dev/null +++ b/plugins/model-providers/zai/plugin.yaml @@ -0,0 +1,5 @@ +name: zai-provider +kind: model-provider +version: 1.0.0 +description: Z.AI / GLM +author: Nous Research diff --git a/providers/README.md b/providers/README.md index 786bc3c2e9..e1aa400f59 100644 --- a/providers/README.md +++ b/providers/README.md @@ -1,307 +1,78 @@ # providers/ -Single source of truth for every inference provider Hermes knows about. +Registry and ABC for every inference provider Hermes knows about. -Each provider is declared once here as a `ProviderProfile`. Every other layer — +Each provider is declared once as a `ProviderProfile`. Every other layer — auth resolution, transport kwargs, model listing, runtime routing — reads from these profiles instead of maintaining its own parallel data. --- -## Directory layout +## Layout ``` providers/ -├── base.py ProviderProfile dataclass + OMIT_TEMPERATURE sentinel -├── __init__.py Registry: register_provider(), get_provider_profile() -├── README.md This file -│ -├── # Simple providers — just identity + auth + endpoint -├── alibaba.py Alibaba Cloud DashScope -├── arcee.py Arcee AI -├── bedrock.py AWS Bedrock (api_mode=bedrock_converse) -├── deepseek.py DeepSeek -├── huggingface.py Hugging Face Inference API -├── kilocode.py Kilo Code -├── minimax.py MiniMax (international + CN) -├── nvidia.py NVIDIA NIM (default_max_tokens=16384) -├── ollama_cloud.py Ollama Cloud -├── stepfun.py StepFun -├── xiaomi.py Xiaomi MiMo -├── xai.py xAI Grok (api_mode=codex_responses) -├── zai.py Z.AI / GLM -│ -├── # Medium — one or two quirks -├── anthropic.py Native Anthropic (x-api-key header, api_mode=anthropic_messages) -├── copilot.py GitHub Copilot (auth_type=copilot, reasoning per model) -├── copilot_acp.py Copilot ACP subprocess (api_mode=copilot_acp) -├── custom.py Custom/Ollama local (think=false, num_ctx) -├── gemini.py Google Gemini AI Studio + Cloud Code OAuth -├── kimi.py Kimi Coding (OMIT_TEMPERATURE, thinking, dual endpoint) -├── openai_codex.py OpenAI Codex OAuth (api_mode=codex_responses) -├── opencode.py OpenCode Zen + Go (per-model api_mode routing) -│ -├── # Complex — subclasses with multiple overrides -├── nous.py Nous Portal (tags, attribution, reasoning omit-when-disabled) -├── openrouter.py OpenRouter (provider preferences, public model fetch) -├── qwen.py Qwen OAuth (message normalization, cache_control, vl_hires) -└── vercel.py Vercel AI Gateway (attribution headers, reasoning passthrough) +├── base.py ProviderProfile dataclass + OMIT_TEMPERATURE sentinel +├── __init__.py Registry: register_provider(), get_provider_profile(), list_providers() +└── README.md This file ``` +The **profiles themselves** live as plugins under +`plugins/model-providers//` (bundled in this repo) and +`$HERMES_HOME/plugins/model-providers//` (per-user overrides). The +registry in `providers/__init__.py` lazily discovers them the first time any +consumer calls `get_provider_profile()` or `list_providers()`. See +`plugins/model-providers/README.md` for the plugin contract and examples. + --- -## ProviderProfile fields +## How it wires in -```python -@dataclass -class ProviderProfile: - # Identity - name: str # canonical ID — auto-registered as PROVIDER_REGISTRY key for new api-key providers - api_mode: str # "chat_completions" | "anthropic_messages" | - # "codex_responses" | "bedrock_converse" | "copilot_acp" - aliases: tuple # alternate names resolved by get_provider_profile() +The registry is populated on first access. After that, every downstream +layer reads from it: - # Auth & endpoints - env_vars: tuple # env var names holding the API key, in priority order - base_url: str # default inference endpoint - models_url: str # explicit models endpoint; falls back to {base_url}/models - # set when the models catalog lives at a different URL - # (e.g. OpenRouter: public /api/v1/models vs /api/v1 inference) - auth_type: str # "api_key" | "oauth_device_code" | "oauth_external" | - # "copilot" | "aws" | "external_process" - - # Client-level quirks - default_headers: dict # extra HTTP headers sent on every request - - # Request-level quirks - fixed_temperature: Any # None = use caller's default; OMIT_TEMPERATURE = don't send - default_max_tokens: int|None # inject max_tokens when caller omits it - default_aux_model: str # cheap model for auxiliary tasks (compression, vision, etc.) - # empty string = use main model (default) -``` +- `hermes_cli/auth.py` extends `PROVIDER_REGISTRY` with every api-key + profile it sees (skipping `copilot`, `kimi-coding`, `kimi-coding-cn`, + `zai`, `openrouter`, `custom` — those need bespoke token resolution). +- `hermes_cli/models.py` extends `CANONICAL_PROVIDERS` and calls + `profile.fetch_models()` inside `provider_model_ids()`. +- `hermes_cli/doctor.py` adds a `/models` health check for each + `auth_type="api_key"` profile. +- `hermes_cli/config.py` injects every `env_var` into + `OPTIONAL_ENV_VARS` so the setup wizard knows about it. +- `hermes_cli/runtime_provider.py` reads `profile.api_mode` as a fallback + when URL detection finds nothing. +- `agent/model_metadata.py` maps hostname → provider via + `profile.get_hostname()`. +- `agent/auxiliary_client.py` reads `profile.default_aux_model` first + before falling back to the legacy hardcoded dict. +- `agent/transports/chat_completions.py::_build_kwargs_from_profile()` + invokes `profile.prepare_messages()`, `profile.build_extra_body()`, + and `profile.build_api_kwargs_extras()` on every call. +- `run_agent.py` passes `provider_profile=` so the + transport takes the profile path instead of the legacy flag path. --- -## Hooks (override in a subclass) +## Adding a provider -| Method | When to override | -|--------|-----------------| -| `prepare_messages(messages)` | Provider needs message pre-processing (Qwen: string → list-of-parts, cache_control) | -| `build_extra_body(*, session_id, **ctx)` | Provider-specific `extra_body` fields (Nous: tags, OpenRouter: provider preferences) | -| `build_api_kwargs_extras(*, reasoning_config, **ctx)` | Returns `(extra_body_additions, top_level_kwargs)` — use when some fields go to `extra_body` and some go top-level (Kimi: `reasoning_effort` top-level; OpenRouter: `reasoning` in extra_body) | -| `fetch_models(*, api_key, timeout)` | Custom model listing (Anthropic: x-api-key header; OpenRouter: public endpoint, no auth; Bedrock/copilot-acp: return None) | - -All hooks have safe defaults — only override what differs from the base. +See `plugins/model-providers/README.md` — drop a new directory there (or +under `$HERMES_HOME/plugins/model-providers/` for a private plugin). --- -## How to add a new provider +## Hooks you can override on `ProviderProfile` -### 1. Simple (standard OpenAI-compatible endpoint) - -```python -# providers/myprovider.py -from providers import register_provider -from providers.base import ProviderProfile - -myprovider = ProviderProfile( - name="myprovider", # must match id in hermes_cli/auth.py PROVIDER_REGISTRY - aliases=("my-provider", "myp"), - api_mode="chat_completions", - env_vars=("MYPROVIDER_API_KEY",), - base_url="https://api.myprovider.com/v1", - auth_type="api_key", -) - -register_provider(myprovider) -``` - -The default `fetch_models()` will call `GET https://api.myprovider.com/v1/models` -with Bearer auth automatically. No override needed for standard `/v1/models`. - -### 2. With quirks (subclass) - -```python -# providers/myprovider.py -from typing import Any -from providers import register_provider -from providers.base import ProviderProfile - - -class MyProviderProfile(ProviderProfile): - """My provider — custom reasoning header.""" - - def build_api_kwargs_extras( - self, - *, - reasoning_config: dict | None = None, - **ctx: Any, - ) -> tuple[dict[str, Any], dict[str, Any]]: - extra_body: dict[str, Any] = {} - if reasoning_config: - extra_body["my_reasoning"] = reasoning_config.get("effort", "medium") - return extra_body, {} - - def fetch_models( - self, - *, - api_key: str | None = None, - timeout: float = 8.0, - ) -> list[str] | None: - # Override only if your endpoint differs from standard /v1/models - return super().fetch_models(api_key=api_key, timeout=timeout) - - -myprovider = MyProviderProfile( - name="myprovider", - aliases=("myp",), - env_vars=("MYPROVIDER_API_KEY",), - base_url="https://api.myprovider.com/v1", -) - -register_provider(myprovider) -``` - -### 3. Wire it up - -After creating the file, add `name` to the `_PROFILE_ACTIVE_PROVIDERS` set in -`run_agent.py` once you've verified parity against the legacy flag path. Start -with a simple provider (no message prep, no reasoning quirks) and work up. +| Hook | Purpose | +|------|---------| +| `get_hostname()` | URL-based detection — default derives from `base_url`. | +| `prepare_messages(msgs)` | Provider-specific message preprocessing (Qwen normalises to list-of-parts, injects `cache_control`). | +| `build_extra_body(**ctx)` | Provider-specific `extra_body` (OpenRouter provider prefs, Gemini `thinking_config`). | +| `build_api_kwargs_extras(**ctx)` | `(extra_body_additions, top_level_kwargs)` — Kimi puts reasoning_effort top-level, Qwen splits `enable_thinking`/`thinking_budget`. | +| `fetch_models(*, api_key)` | Live catalog fetch — default hits `{models_url or base_url}/models` with Bearer auth. Override for no-REST providers (Bedrock), OAuth catalogs (Anthropic), or public catalogs (OpenRouter). | --- -## fetch_models contract +## Configuration fields -```python -def fetch_models( - self, - *, - api_key: str | None = None, - timeout: float = 8.0, -) -> list[str] | None: - ... -``` - -- Returns `list[str]`: model IDs from the provider's live endpoint. -- Returns `None`: provider doesn't support REST model listing (Bedrock, copilot-acp), - or the request failed. Callers **must** fall back to `_PROVIDER_MODELS` on `None`. -- Never raises — swallow exceptions and return `None`. -- Default implementation: `GET {base_url}/models` with Bearer auth. Works for any - standard OpenAI-compatible provider. - -**Override when:** -- Auth header is not `Bearer` (Anthropic: `x-api-key`) -- Endpoint path differs from `/models` AND you can't just set `models_url` (OpenRouter: public endpoint, pass `api_key=None` explicitly) -- Response format differs (extra wrapping, non-standard `id` field) -- Provider has no REST endpoint (Bedrock, copilot-acp → return `None`) -- Filtering needed post-fetch (only tool-capable models, etc.) - -Use `models_url` instead of overriding when the only difference is the URL: - -```python -# No subclass needed — just set models_url -myprovider = ProviderProfile( - name="myprovider", - base_url="https://api.myprovider.com/v1", - models_url="https://catalog.myprovider.com/models", # different host -) -``` - ---- - -## Debugging - -### Check if a provider resolves - -```python -from providers import get_provider_profile - -p = get_provider_profile("myprovider") -print(p) # ProviderProfile(name='myprovider', ...) -print(p.base_url) -print(p.api_mode) -``` - -### Check all registered providers - -```python -from providers import _REGISTRY -print(list(_REGISTRY.keys())) -``` - -### Test live model fetch - -```python -import os -from providers import get_provider_profile - -p = get_provider_profile("myprovider") -key = os.getenv("MYPROVIDER_API_KEY") -models = p.fetch_models(api_key=key, timeout=5.0) -print(models) # list of model IDs, or None on failure -``` - -### Test alias resolution - -```python -from providers import get_provider_profile - -# All of these should return the same profile -assert get_provider_profile("openrouter").name == "openrouter" -assert get_provider_profile("or").name == "openrouter" -``` - -### Run the provider test suite - -```bash -# From the repo root -source venv/bin/activate -python -m pytest tests/providers/ -v -``` - -### Check ruff + ty compliance - -```bash -source venv/bin/activate -ruff format providers/*.py -ruff check providers/*.py --select UP,E,F,I,W -ty check providers/*.py -``` - ---- - -## Common mistakes - -**Wrong `name`** — must be the same string that appears as the key in -`hermes_cli/auth.py` `PROVIDER_REGISTRY`. New api-key providers auto-register -into `PROVIDER_REGISTRY` from the profile, so the name IS the key. For providers -with a pre-existing `PROVIDER_REGISTRY` entry, use the exact `id` field value. - -**Wrong `env_vars`** — separate API-key vars from base-URL override vars in the -tuple. Env vars that end with `_BASE_URL` or `_URL` are treated as URL overrides; -everything else is treated as an API key. Getting this wrong causes the doctor -health check to send a URL string as a Bearer token. - -**Wrong `base_url`** — several providers have non-obvious paths: -`stepfun: /step_plan/v1`, `opencode-go: /zen/go/v1`. The profile's `base_url` -is also used as the `inference_base_url` when auto-registering into `PROVIDER_REGISTRY` -for new providers, so it must be correct for auth resolution to work. - -**Skipping `api_mode`** — defaults to `chat_completions`. Providers that use -`anthropic_messages`, `codex_responses`, `bedrock_converse`, or `copilot_acp` -must set it explicitly. - -**Forgetting `register_provider()`** — auto-discovery runs `pkgutil.iter_modules` -over the package and imports each module, but only if `register_provider()` is -called at module level. Without it the profile is never in `_REGISTRY`. - -**`fetch_models` returning the wrong shape** — must return `list[str]` (plain -model IDs), not `list[tuple]` or `list[dict]`. Callers expect plain strings. - -**Wrong `build_api_kwargs_extras` return shape** — must return a 2-tuple -`(extra_body_dict, top_level_dict)`. Returning a single dict causes a -`ValueError: not enough values to unpack` in the transport. - -**`build_api_kwargs_extras` wrong tuple** — must return `(extra_body_dict, -top_level_dict)`. Returning a flat dict or swapping the order silently sends -fields to the wrong place. +Full reference in `providers/base.py` dataclass definition. diff --git a/providers/__init__.py b/providers/__init__.py index 9c80b449a9..a394e74b33 100644 --- a/providers/__init__.py +++ b/providers/__init__.py @@ -1,25 +1,62 @@ """Provider module registry. -Auto-discovers ProviderProfile instances from providers/*.py modules. -Each module should define a module-level PROVIDER or PROVIDERS list. +Provider profiles can live in two places: + +1. Bundled plugins: ``plugins/model-providers//`` (shipped with hermes-agent) +2. User plugins: ``$HERMES_HOME/plugins/model-providers//`` + +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:: -Usage: from providers import get_provider_profile - profile = get_provider_profile("nvidia") # returns ProviderProfile or None - profile = get_provider_profile("kimi") # checks name + aliases + 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.""" + """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 @@ -51,26 +88,104 @@ def list_providers() -> list[ProviderProfile]: 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.``) + # 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: - """Import all provider modules to trigger registration.""" + """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//`` + 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 - import importlib - import pkgutil + # 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") - import providers as _pkg + # 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") - 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 e: - import logging + # 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 - logging.getLogger(__name__).warning( - "Failed to import provider module %s: %s", modname, e - ) + 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 diff --git a/tests/providers/test_plugin_discovery.py b/tests/providers/test_plugin_discovery.py new file mode 100644 index 0000000000..9ad6713e3e --- /dev/null +++ b/tests/providers/test_plugin_discovery.py @@ -0,0 +1,145 @@ +"""Tests for the model-providers plugin discovery system. + +Verifies that: + 1. All bundled providers at plugins/model-providers// are discovered + 2. User plugins at $HERMES_HOME/plugins/model-providers// override bundled + 3. plugin.yaml manifests with kind=model-provider are correctly categorized +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _clear_provider_caches(): + """Force providers/__init__.py to re-discover on next list_providers().""" + import providers as _pkg + _pkg._REGISTRY.clear() + _pkg._ALIASES.clear() + _pkg._discovered = False + # Evict any cached plugin modules so the next import re-executes. + for mod in list(sys.modules.keys()): + if ( + mod.startswith("plugins.model_providers") + or mod.startswith("_hermes_user_provider") + ): + del sys.modules[mod] + + +def test_bundled_plugins_discovered(): + """Every plugins/model-providers// should contain a plugin.yaml + __init__.py.""" + plugins_dir = REPO_ROOT / "plugins" / "model-providers" + assert plugins_dir.is_dir(), f"Missing {plugins_dir}" + + child_dirs = [c for c in plugins_dir.iterdir() if c.is_dir()] + assert len(child_dirs) >= 28, f"Expected at least 28 provider plugins, found {len(child_dirs)}" + + for child in child_dirs: + assert (child / "__init__.py").exists(), f"{child.name} missing __init__.py" + assert (child / "plugin.yaml").exists(), f"{child.name} missing plugin.yaml" + + +def test_all_33_profiles_register(): + """After discovery, the registry must contain exactly 33 distinct profiles.""" + _clear_provider_caches() + from providers import list_providers + + profiles = list_providers() + names = sorted(p.name for p in profiles) + assert len(names) == 33, f"Expected 33 profiles, got {len(names)}: {names}" + + # Spot-check representative providers from different categories + for required in ( + "openrouter", "anthropic", "custom", "bedrock", "openai-codex", + "minimax-oauth", "gmi", "xiaomi", "alibaba-coding-plan", + ): + assert required in names, f"Missing profile: {required}" + + +def test_user_plugin_overrides_bundled(tmp_path, monkeypatch): + """A user plugin with the same name must override the bundled profile.""" + # Point HERMES_HOME at a fresh temp dir + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + # get_hermes_home() may be module-cached depending on codebase; ensure the + # env var is the source of truth. Most code paths re-read it each call. + + # Drop a user plugin that replaces 'gmi' + user_gmi = hermes_home / "plugins" / "model-providers" / "gmi" + user_gmi.mkdir(parents=True) + (user_gmi / "__init__.py").write_text( + "from providers import register_provider\n" + "from providers.base import ProviderProfile\n" + "\n" + "custom_gmi = ProviderProfile(\n" + ' name="gmi",\n' + ' aliases=("gmi-user-override-test",),\n' + ' env_vars=("GMI_API_KEY",),\n' + ' base_url="https://user-override.example.com/v1",\n' + ' auth_type="api_key",\n' + ")\n" + "register_provider(custom_gmi)\n" + ) + (user_gmi / "plugin.yaml").write_text( + "name: gmi-user-override\n" + "kind: model-provider\n" + "version: 0.0.1\n" + "description: Test user override\n" + ) + + _clear_provider_caches() + from providers import get_provider_profile + + gmi = get_provider_profile("gmi") + assert gmi is not None + assert gmi.base_url == "https://user-override.example.com/v1", ( + f"User override not applied; got base_url={gmi.base_url!r}" + ) + assert "gmi-user-override-test" in gmi.aliases + + # Clean up: reset discovery state so other tests see the bundled version + _clear_provider_caches() + + +def test_general_plugin_manager_skips_model_provider_kind(tmp_path, monkeypatch): + """The general PluginManager must NOT import model-provider plugins + (providers/__init__.py handles them). It records the manifest only.""" + from hermes_cli import plugins as plugin_mod + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + # Create a user-installed plugin with an explicit kind: model-provider. + user_plugin = hermes_home / "plugins" / "test-model-provider" + user_plugin.mkdir(parents=True) + (user_plugin / "plugin.yaml").write_text( + "name: test-model-provider\n" + "kind: model-provider\n" + "version: 0.0.1\n" + ) + (user_plugin / "__init__.py").write_text( + # Intentionally broken import — if the general loader tries to + # import this module, the test will fail with ImportError. + "raise AssertionError('model-provider plugins must not be imported by PluginManager')\n" + ) + + # Fresh manager + manager = plugin_mod.PluginManager() + manager.discover_and_load(force=True) + + # The manifest should be recorded but not loaded + loaded = manager._plugins.get("test-model-provider") + assert loaded is not None + assert loaded.manifest.kind == "model-provider" + # No import means the module must NOT be in the plugins list as a loaded one. + # We check that the general loader didn't crash and didn't raise from the + # broken __init__.py. diff --git a/website/docs/developer-guide/adding-providers.md b/website/docs/developer-guide/adding-providers.md index 5ec127d663..3cd358849a 100644 --- a/website/docs/developer-guide/adding-providers.md +++ b/website/docs/developer-guide/adding-providers.md @@ -99,10 +99,12 @@ If your provider is just an OpenAI-compatible endpoint that authenticates with a All you need is: -1. A file in `providers/` (e.g. `providers/myprovider.py`) that calls `register_provider()` with the provider config. -2. That's it. `auth.py` auto-registers every file in `providers/` at startup via a module-level import sweep. +1. A plugin directory under `plugins/model-providers//` containing: + - `__init__.py` — calls `register_provider(profile)` at module-level + - `plugin.yaml` — manifest (name, kind: model-provider, version, description) +2. That's it. Provider plugins auto-load the first time anything calls `get_provider_profile()` or `list_providers()` — bundled plugins (this repo) and user plugins at `$HERMES_HOME/plugins/model-providers/` both get picked up. -When you add a `providers/*.py` file and call `register_provider()`, the following wire up automatically: +When you add a plugin and it calls `register_provider()`, the following wire up automatically: 1. `PROVIDER_REGISTRY` entry in `auth.py` (credential resolution, env-var lookup) 2. `api_mode` set to `chat_completions` @@ -117,7 +119,9 @@ When you add a `providers/*.py` file and call `register_provider()`, the followi 11. `HERMES_INFERENCE_PROVIDER` env-var override accepts the provider id 12. Fallback model activation can switch into the provider cleanly -See `providers/nvidia.py` or `providers/gmi.py` as a template. +User plugins at `$HERMES_HOME/plugins/model-providers//` override bundled plugins of the same name (last-writer-wins in `register_provider()`) — so third parties can monkey-patch or replace any built-in profile without editing the repo. + +See `plugins/model-providers/nvidia/` or `plugins/model-providers/gmi/` as a template, and `plugins/model-providers/README.md` for the full contract. ## Full path: OAuth and complex providers diff --git a/website/docs/developer-guide/provider-runtime.md b/website/docs/developer-guide/provider-runtime.md index b2e798a267..40d6cd7d9a 100644 --- a/website/docs/developer-guide/provider-runtime.md +++ b/website/docs/developer-guide/provider-runtime.md @@ -20,9 +20,10 @@ Primary implementation: - `hermes_cli/auth.py` — provider registry, `resolve_provider()` - `hermes_cli/model_switch.py` — shared `/model` switch pipeline (CLI + gateway) - `agent/auxiliary_client.py` — auxiliary model routing -- `providers/` — declarative source for `api_mode`, `base_url`, `env_vars`, `fallback_models` (auto-registered into `auth.py` `PROVIDER_REGISTRY` at startup) +- `providers/` — ABC + registry entry points (`ProviderProfile`, `register_provider`, `get_provider_profile`, `list_providers`) +- `plugins/model-providers//` — per-provider plugins (bundled) that declare `api_mode`, `base_url`, `env_vars`, `fallback_models` and register themselves into the registry on first access. User plugins at `$HERMES_HOME/plugins/model-providers//` override bundled ones of the same name. -`get_provider_profile()` in `providers/` returns a typed dict for a given provider id. `runtime_provider.py` calls this at resolution time to get the canonical `base_url`, `env_vars` priority list, `api_mode`, and `fallback_models` without needing to duplicate that data in multiple files. Adding a new `providers/*.py` file that calls `register_provider()` is enough for `runtime_provider.py` to pick it up — no branch needed in the resolver itself. +`get_provider_profile()` in `providers/` returns a `ProviderProfile` for a given provider id. `runtime_provider.py` calls this at resolution time to get the canonical `base_url`, `env_vars` priority list, `api_mode`, and `fallback_models` without needing to duplicate that data in multiple files. Adding a new plugin under `plugins/model-providers//` (or `$HERMES_HOME/plugins/model-providers//`) that calls `register_provider()` is enough for `runtime_provider.py` to pick it up — no branch needed in the resolver itself. If you are trying to add a new first-class inference provider, read [Adding Providers](./adding-providers.md) alongside this page.