Every conversation-affinity hint Hermes sends is derived from the PHYSICAL session id: prompt_cache_key on both OpenAI-wire transports, OpenRouter's and Nous Portal's sticky session_id, and xAI's x-grok-conv-id. A host that mints one physical session per RESPONSE re-keys all four on every reply, so the conversation never lands back on the routing bucket it just warmed (#96811). Two hosts do exactly that. Hermes Studio's group chat mints gc_run_<room>_<profile>_<name>_<uuid4hex> per reply and destroys it after, and POST /v1/responses with client-managed history mints str(uuid4()) per request — while parsing X-Hermes-Session-Key one screen earlier and handing it to the agent. Hermes must not infer the logical conversation from the id's syntax: that rule merges independent client-supplied ids and Studio members truncated past its 96-character boundary (the #79017 failure class). It does not have to. gateway_session_key is already the "stable per-chat key" built by gateway.session.build_session_key from that header, and branching deliberately does not key off it. The affinity path simply never consulted it. - agent/prompt_cache_scope.py: declared_conversation_scope() resolves the key into gwk_<sha256[:24]> and outranks the lineage walk (it is stable across rotation AND across per-response ids). Hashed because, unlike a session id, the key embeds platform/chat/user identifiers and leaves the process verbatim as a sticky id and as x-grok-conv-id. - agent/portal_tags.py: a separate ambient scope for ROUTING, published only when a host declared one. The providers read the attribution id when it is unset, so delegate trees keep sharing their parent's sticky key and every host that keeps one id per conversation is byte-identical to before. - hermes_state.py: is_explicit_fork_child() — the public view of the marker rules that keep /branch children, delegate subagents and tool children off their parent's chat key. Background-review forks clone the live runtime, so _persist_disabled excludes them for the same reason (#79161). Refs #96570 Fixes #96811
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
-
Create
plugins/model-providers/<your_provider>/__init__.py: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) -
Create
plugins/model-providers/<your_provider>/plugin.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.