mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
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.
This commit is contained in:
parent
20a4f79ed1
commit
9022804d78
63 changed files with 585 additions and 309 deletions
|
|
@ -173,7 +173,7 @@ def _get_enabled_plugins() -> Optional[set]:
|
||||||
# Data classes
|
# Data classes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform"}
|
_VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform", "model-provider"}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -643,15 +643,17 @@ class PluginManager:
|
||||||
# - flat: ``plugins/disk-cleanup/plugin.yaml`` (standalone)
|
# - flat: ``plugins/disk-cleanup/plugin.yaml`` (standalone)
|
||||||
# - category: ``plugins/image_gen/openai/plugin.yaml`` (backend)
|
# - category: ``plugins/image_gen/openai/plugin.yaml`` (backend)
|
||||||
#
|
#
|
||||||
# ``memory/`` and ``context_engine/`` are skipped at the top level —
|
# ``memory/``, ``context_engine/``, and ``model-providers/`` are
|
||||||
# they have their own discovery systems. ``platforms/`` is a category
|
# skipped at the top level — they have their own discovery systems
|
||||||
# holding platform adapters (scanned one level deeper below).
|
# (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()
|
repo_plugins = get_bundled_plugins_dir()
|
||||||
manifests.extend(
|
manifests.extend(
|
||||||
self._scan_directory(
|
self._scan_directory(
|
||||||
repo_plugins,
|
repo_plugins,
|
||||||
source="bundled",
|
source="bundled",
|
||||||
skip_names={"memory", "context_engine", "platforms"},
|
skip_names={"memory", "context_engine", "platforms", "model-providers"},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
manifests.extend(
|
manifests.extend(
|
||||||
|
|
@ -709,6 +711,21 @@ class PluginManager:
|
||||||
)
|
)
|
||||||
continue
|
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
|
# Built-in backends auto-load — they ship with hermes and must
|
||||||
# just work. Selection among them (e.g. which image_gen backend
|
# just work. Selection among them (e.g. which image_gen backend
|
||||||
# services calls) is driven by ``<category>.provider`` config,
|
# services calls) is driven by ``<category>.provider`` config,
|
||||||
|
|
@ -886,6 +903,19 @@ class PluginManager:
|
||||||
"treating as kind='exclusive'",
|
"treating as kind='exclusive'",
|
||||||
key,
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
70
plugins/model-providers/README.md
Normal file
70
plugins/model-providers/README.md
Normal file
|
|
@ -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/<name>/` 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/<your_provider>/__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/<your_provider>/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.
|
||||||
5
plugins/model-providers/ai-gateway/plugin.yaml
Normal file
5
plugins/model-providers/ai-gateway/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: ai-gateway-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: Vercel AI Gateway
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/alibaba-coding-plan/plugin.yaml
Normal file
5
plugins/model-providers/alibaba-coding-plan/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: alibaba-coding-plan-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: Alibaba Cloud Coding Plan
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/alibaba/plugin.yaml
Normal file
5
plugins/model-providers/alibaba/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: alibaba-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: Alibaba DashScope (international)
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/anthropic/plugin.yaml
Normal file
5
plugins/model-providers/anthropic/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: anthropic-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: Anthropic (Claude)
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/arcee/plugin.yaml
Normal file
5
plugins/model-providers/arcee/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: arcee-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: Arcee AI
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/azure-foundry/plugin.yaml
Normal file
5
plugins/model-providers/azure-foundry/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: azure-foundry-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: Azure AI Foundry
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/bedrock/plugin.yaml
Normal file
5
plugins/model-providers/bedrock/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: bedrock-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: AWS Bedrock
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/copilot-acp/plugin.yaml
Normal file
5
plugins/model-providers/copilot-acp/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: copilot-acp-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: GitHub Copilot via ACP subprocess
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/copilot/plugin.yaml
Normal file
5
plugins/model-providers/copilot/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: copilot-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: GitHub Copilot
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/custom/plugin.yaml
Normal file
5
plugins/model-providers/custom/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: custom-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: Custom / Ollama / local OpenAI-compatible endpoint
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/deepseek/plugin.yaml
Normal file
5
plugins/model-providers/deepseek/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: deepseek-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: DeepSeek
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/gemini/plugin.yaml
Normal file
5
plugins/model-providers/gemini/plugin.yaml
Normal file
|
|
@ -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
|
||||||
5
plugins/model-providers/gmi/plugin.yaml
Normal file
5
plugins/model-providers/gmi/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: gmi-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: GMI Cloud
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/huggingface/plugin.yaml
Normal file
5
plugins/model-providers/huggingface/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: huggingface-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: HuggingFace Inference Providers
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/kilocode/plugin.yaml
Normal file
5
plugins/model-providers/kilocode/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: kilocode-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: Kilo Code
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/kimi-coding/plugin.yaml
Normal file
5
plugins/model-providers/kimi-coding/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: kimi-coding-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: Moonshot Kimi Coding (global + China)
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/minimax/plugin.yaml
Normal file
5
plugins/model-providers/minimax/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: minimax-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: MiniMax M-series (global + China + OAuth)
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/nous/plugin.yaml
Normal file
5
plugins/model-providers/nous/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: nous-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: Nous Research Portal
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/nvidia/plugin.yaml
Normal file
5
plugins/model-providers/nvidia/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: nvidia-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: NVIDIA NIM
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/ollama-cloud/plugin.yaml
Normal file
5
plugins/model-providers/ollama-cloud/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: ollama-cloud-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: Ollama Cloud
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/openai-codex/plugin.yaml
Normal file
5
plugins/model-providers/openai-codex/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: openai-codex-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: OpenAI Codex (Responses API)
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/opencode-zen/plugin.yaml
Normal file
5
plugins/model-providers/opencode-zen/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: opencode-zen-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: OpenCode (Zen + Go)
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/openrouter/plugin.yaml
Normal file
5
plugins/model-providers/openrouter/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: openrouter-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: OpenRouter aggregator
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/qwen-oauth/plugin.yaml
Normal file
5
plugins/model-providers/qwen-oauth/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: qwen-oauth-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: Qwen Portal (OAuth)
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/stepfun/plugin.yaml
Normal file
5
plugins/model-providers/stepfun/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: stepfun-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: StepFun Step Plan
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/xai/plugin.yaml
Normal file
5
plugins/model-providers/xai/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: xai-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: xAI Grok (Responses API)
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/xiaomi/plugin.yaml
Normal file
5
plugins/model-providers/xiaomi/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: xiaomi-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: Xiaomi MiMo
|
||||||
|
author: Nous Research
|
||||||
5
plugins/model-providers/zai/plugin.yaml
Normal file
5
plugins/model-providers/zai/plugin.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
name: zai-provider
|
||||||
|
kind: model-provider
|
||||||
|
version: 1.0.0
|
||||||
|
description: Z.AI / GLM
|
||||||
|
author: Nous Research
|
||||||
|
|
@ -1,307 +1,78 @@
|
||||||
# providers/
|
# 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
|
auth resolution, transport kwargs, model listing, runtime routing — reads from
|
||||||
these profiles instead of maintaining its own parallel data.
|
these profiles instead of maintaining its own parallel data.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Directory layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
providers/
|
providers/
|
||||||
├── base.py ProviderProfile dataclass + OMIT_TEMPERATURE sentinel
|
├── base.py ProviderProfile dataclass + OMIT_TEMPERATURE sentinel
|
||||||
├── __init__.py Registry: register_provider(), get_provider_profile()
|
├── __init__.py Registry: register_provider(), get_provider_profile(), list_providers()
|
||||||
├── README.md This file
|
└── 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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The **profiles themselves** live as plugins under
|
||||||
|
`plugins/model-providers/<name>/` (bundled in this repo) and
|
||||||
|
`$HERMES_HOME/plugins/model-providers/<name>/` (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
|
The registry is populated on first access. After that, every downstream
|
||||||
@dataclass
|
layer reads from it:
|
||||||
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()
|
|
||||||
|
|
||||||
# Auth & endpoints
|
- `hermes_cli/auth.py` extends `PROVIDER_REGISTRY` with every api-key
|
||||||
env_vars: tuple # env var names holding the API key, in priority order
|
profile it sees (skipping `copilot`, `kimi-coding`, `kimi-coding-cn`,
|
||||||
base_url: str # default inference endpoint
|
`zai`, `openrouter`, `custom` — those need bespoke token resolution).
|
||||||
models_url: str # explicit models endpoint; falls back to {base_url}/models
|
- `hermes_cli/models.py` extends `CANONICAL_PROVIDERS` and calls
|
||||||
# set when the models catalog lives at a different URL
|
`profile.fetch_models()` inside `provider_model_ids()`.
|
||||||
# (e.g. OpenRouter: public /api/v1/models vs /api/v1 inference)
|
- `hermes_cli/doctor.py` adds a `/models` health check for each
|
||||||
auth_type: str # "api_key" | "oauth_device_code" | "oauth_external" |
|
`auth_type="api_key"` profile.
|
||||||
# "copilot" | "aws" | "external_process"
|
- `hermes_cli/config.py` injects every `env_var` into
|
||||||
|
`OPTIONAL_ENV_VARS` so the setup wizard knows about it.
|
||||||
# Client-level quirks
|
- `hermes_cli/runtime_provider.py` reads `profile.api_mode` as a fallback
|
||||||
default_headers: dict # extra HTTP headers sent on every request
|
when URL detection finds nothing.
|
||||||
|
- `agent/model_metadata.py` maps hostname → provider via
|
||||||
# Request-level quirks
|
`profile.get_hostname()`.
|
||||||
fixed_temperature: Any # None = use caller's default; OMIT_TEMPERATURE = don't send
|
- `agent/auxiliary_client.py` reads `profile.default_aux_model` first
|
||||||
default_max_tokens: int|None # inject max_tokens when caller omits it
|
before falling back to the legacy hardcoded dict.
|
||||||
default_aux_model: str # cheap model for auxiliary tasks (compression, vision, etc.)
|
- `agent/transports/chat_completions.py::_build_kwargs_from_profile()`
|
||||||
# empty string = use main model (default)
|
invokes `profile.prepare_messages()`, `profile.build_extra_body()`,
|
||||||
```
|
and `profile.build_api_kwargs_extras()` on every call.
|
||||||
|
- `run_agent.py` passes `provider_profile=<ProviderProfile>` 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 |
|
See `plugins/model-providers/README.md` — drop a new directory there (or
|
||||||
|--------|-----------------|
|
under `$HERMES_HOME/plugins/model-providers/` for a private plugin).
|
||||||
| `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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How to add a new provider
|
## Hooks you can override on `ProviderProfile`
|
||||||
|
|
||||||
### 1. Simple (standard OpenAI-compatible endpoint)
|
| Hook | Purpose |
|
||||||
|
|------|---------|
|
||||||
```python
|
| `get_hostname()` | URL-based detection — default derives from `base_url`. |
|
||||||
# providers/myprovider.py
|
| `prepare_messages(msgs)` | Provider-specific message preprocessing (Qwen normalises to list-of-parts, injects `cache_control`). |
|
||||||
from providers import register_provider
|
| `build_extra_body(**ctx)` | Provider-specific `extra_body` (OpenRouter provider prefs, Gemini `thinking_config`). |
|
||||||
from providers.base import ProviderProfile
|
| `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). |
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## fetch_models contract
|
## Configuration fields
|
||||||
|
|
||||||
```python
|
Full reference in `providers/base.py` dataclass definition.
|
||||||
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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,62 @@
|
||||||
"""Provider module registry.
|
"""Provider module registry.
|
||||||
|
|
||||||
Auto-discovers ProviderProfile instances from providers/*.py modules.
|
Provider profiles can live in two places:
|
||||||
Each module should define a module-level PROVIDER or PROVIDERS list.
|
|
||||||
|
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::
|
||||||
|
|
||||||
Usage:
|
|
||||||
from providers import get_provider_profile
|
from providers import get_provider_profile
|
||||||
profile = get_provider_profile("nvidia") # returns ProviderProfile or None
|
profile = get_provider_profile("nvidia") # ProviderProfile or None
|
||||||
profile = get_provider_profile("kimi") # checks name + aliases
|
profile = get_provider_profile("kimi") # checks name + aliases
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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
|
from providers.base import OMIT_TEMPERATURE, ProviderProfile # noqa: F401
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_REGISTRY: dict[str, ProviderProfile] = {}
|
_REGISTRY: dict[str, ProviderProfile] = {}
|
||||||
_ALIASES: dict[str, str] = {}
|
_ALIASES: dict[str, str] = {}
|
||||||
_discovered = False
|
_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:
|
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
|
_REGISTRY[profile.name] = profile
|
||||||
for alias in profile.aliases:
|
for alias in profile.aliases:
|
||||||
_ALIASES[alias] = profile.name
|
_ALIASES[alias] = profile.name
|
||||||
|
|
@ -51,26 +88,104 @@ def list_providers() -> list[ProviderProfile]:
|
||||||
return result
|
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:
|
def _discover_providers() -> None:
|
||||||
"""Import all provider modules to trigger registration."""
|
"""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
|
global _discovered
|
||||||
if _discovered:
|
if _discovered:
|
||||||
return
|
return
|
||||||
_discovered = True
|
_discovered = True
|
||||||
|
|
||||||
import importlib
|
# 1. Bundled plugins — shipped with hermes-agent.
|
||||||
import pkgutil
|
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/<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")
|
||||||
|
|
||||||
for _importer, modname, _ispkg in pkgutil.iter_modules(_pkg.__path__):
|
# 3. Legacy single-file profiles at providers/<name>.py. Kept for
|
||||||
if modname.startswith("_") or modname == "base":
|
# back-compat — if someone drops a ``providers/foo.py`` into an
|
||||||
continue
|
# editable install, it still works without the plugin layout.
|
||||||
try:
|
try:
|
||||||
importlib.import_module(f"providers.{modname}")
|
import pkgutil
|
||||||
except ImportError as e:
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logging.getLogger(__name__).warning(
|
import providers as _pkg
|
||||||
"Failed to import provider module %s: %s", modname, e
|
|
||||||
)
|
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
|
||||||
|
|
|
||||||
145
tests/providers/test_plugin_discovery.py
Normal file
145
tests/providers/test_plugin_discovery.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
"""Tests for the model-providers plugin discovery system.
|
||||||
|
|
||||||
|
Verifies that:
|
||||||
|
1. All bundled providers at plugins/model-providers/<name>/ are discovered
|
||||||
|
2. User plugins at $HERMES_HOME/plugins/model-providers/<name>/ 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/<name>/ 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.
|
||||||
|
|
@ -99,10 +99,12 @@ If your provider is just an OpenAI-compatible endpoint that authenticates with a
|
||||||
|
|
||||||
All you need is:
|
All you need is:
|
||||||
|
|
||||||
1. A file in `providers/` (e.g. `providers/myprovider.py`) that calls `register_provider()` with the provider config.
|
1. A plugin directory under `plugins/model-providers/<your-provider>/` containing:
|
||||||
2. That's it. `auth.py` auto-registers every file in `providers/` at startup via a module-level import sweep.
|
- `__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)
|
1. `PROVIDER_REGISTRY` entry in `auth.py` (credential resolution, env-var lookup)
|
||||||
2. `api_mode` set to `chat_completions`
|
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
|
11. `HERMES_INFERENCE_PROVIDER` env-var override accepts the provider id
|
||||||
12. Fallback model activation can switch into the provider cleanly
|
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/<name>/` 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
|
## Full path: OAuth and complex providers
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,10 @@ Primary implementation:
|
||||||
- `hermes_cli/auth.py` — provider registry, `resolve_provider()`
|
- `hermes_cli/auth.py` — provider registry, `resolve_provider()`
|
||||||
- `hermes_cli/model_switch.py` — shared `/model` switch pipeline (CLI + gateway)
|
- `hermes_cli/model_switch.py` — shared `/model` switch pipeline (CLI + gateway)
|
||||||
- `agent/auxiliary_client.py` — auxiliary model routing
|
- `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/<name>/` — 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/<name>/` 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/<your-provider>/` (or `$HERMES_HOME/plugins/model-providers/<your-provider>/`) 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.
|
If you are trying to add a new first-class inference provider, read [Adding Providers](./adding-providers.md) alongside this page.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue