* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule The PLUGIN-COMPAT layer (2776813df3+d63e380324+0a5164cebe) kept pre-#102117 import paths alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader has already been skipping plugins that use the old paths. This removes the layer itself: - 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party names, restored dead definitions) and the three re-export stub modules (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils) - COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step - the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section, the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the `plugins.allow_deprecated_imports` escape hatch An external plugin that still imports an old path now fails to load with its ImportError as the reason in `hermes plugins list`, the same path as any broken plugin. hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect, summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout swap (tests/compat/old_updater_surface.json). In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its `shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed `run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the seam production reads, like every sibling test in that file. * chore: retrigger CI (zero-job startup_failure phantom) * test: drop resolution allowlist rows for the two deleted which() sites hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available (a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires their allowlist entries go with them.
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.