`hermes plugins install owner/repo` — and the plugin index behind `hermes plugins search` — clones into `$HERMES_HOME/plugins/<name>/`, flat, one directory per plugin. Provider discovery only ever scanned `$HERMES_HOME/plugins/model-providers/<name>/`. Nothing joined the two. `PluginManager` does not close the gap either: it classifies `kind: model-provider` and deliberately skips importing it, because provider lifecycle is owned by `providers/__init__.py` — which was not looking in the directory the installer writes to. So the documented install path half-worked. The CLI reported success, wrote its install metadata, and the provider silently did not exist: `hermes -m <it>` said "Unknown provider" and `/model` never listed it. Verified before the fix — a plugin at `~/.hermes/plugins/<name>/` was NOT FOUND while the identical plugin at `~/.hermes/plugins/model-providers/<name>/` was discovered. Discovery now also walks the flat directory, importing only entries whose manifest declares `kind: model-provider`. Everything else there belongs to `PluginManager`, which owns its lifecycle and consent flow — importing it here would run third-party code behind its back, so the tests assert we don't (with fixtures that register on import, since a fixture that merely raised would be swallowed by `_import_plugin_dir` and prove nothing). Manifests are parsed with PyYAML when present and a line scan otherwise, so provider discovery gains no hard dependency; an unreadable manifest is skipped rather than allowed to blank the registry. Co-Authored-By: Junie <junie@jetbrains.com>
providers/
Registry and ABC for every inference provider Hermes knows about.
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.
Layout
providers/
├── 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/<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.
How it wires in
The registry is populated on first access. After that, every downstream layer reads from it:
hermes_cli/auth.pyextendsPROVIDER_REGISTRYwith every api-key profile it sees (skippingcopilot,kimi-coding,kimi-coding-cn,zai,openrouter,custom— those need bespoke token resolution).hermes_cli/models.pyextendsCANONICAL_PROVIDERSand callsprofile.fetch_models()insideprovider_model_ids().hermes_cli/doctor.pyadds a/modelshealth check for eachauth_type="api_key"profile.hermes_cli/config.pyinjects everyenv_varintoOPTIONAL_ENV_VARSso the setup wizard knows about it.hermes_cli/runtime_provider.pyreadsprofile.api_modeas a fallback when URL detection finds nothing.agent/model_metadata.pymaps hostname → provider viaprofile.get_hostname().agent/auxiliary_client.pyreadsprofile.default_aux_modelfirst before falling back to the legacy hardcoded dict.agent/transports/chat_completions.py::_build_kwargs_from_profile()invokesprofile.prepare_messages(),profile.build_extra_body(), andprofile.build_api_kwargs_extras()on every call.run_agent.pypassesprovider_profile=<ProviderProfile>so the transport takes the profile path instead of the legacy flag path.
Adding a provider
See plugins/model-providers/README.md — drop a new directory there (or
under $HERMES_HOME/plugins/model-providers/ for a private plugin).
Hooks you can override on ProviderProfile
| 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. |
supported_reasoning_efforts(model) |
Declared per-model reasoning-effort vocabulary for gateways that 400 on unknown levels (Ramp Router reads its live catalog). None = defer to transport defaults, () = model takes no reasoning params, tuple = clamp target. Must be cache-only — called on the request hot path. |
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). |
Configuration fields
Full reference in providers/base.py dataclass definition.