You wrote a provider plugin for your own gateway. Why does Hermes still treat it as a second-class citizen?

You wrote a Hermes provider plugin for your company’s inference gateway. It shows up in hermes model, it answers, your teammates installed it from your README. Then the paper cuts start: the /model picker never shows the reasoning badge for it; you paste a screenshot of an architecture diagram and the model replies as if there were no image; /usage is permanently blank, so you keep a browser tab open next to it; and a new teammate still has to copy a token out of chat into .env.
Four gaps, one cause: core looked up everything in the public models.dev catalog, and your model is not in that catalog. Three hooks merged on 20 September (#116570, #116555, #116576 — all part of the #116408 wave) hand three jobs back to the plugin: declaring capabilities, reporting usage, and running the login.
1. Model capabilities: declare once, every consumer believes you
Start with the root cause. get_model_capabilities resolved from exactly two places — the models.dev catalog and the user’s model_overrides in config.yaml. Neither knows an out-of-tree provider’s models, so “unknown” was treated as “unsupported” downstream: image routing decided text (passing your screenshot through as text), the picker skipped the badge, and context length came back empty.
A plugin can now declare it on the profile:
from providers import register_provider
from providers.base import ProviderProfile
register_provider(ProviderProfile(
name="acme",
auth_type="api_key",
env_vars=("ACME_API_KEY",),
base_url="https://api.acme.example/v1",
model_capabilities={
"acme-large-high": {
"supports_reasoning": False, # the reasoning tier is fixed by the model id
"supports_vision": True,
"supports_tools": True,
"context_window": 200000,
"model_family": "acme",
},
"acme-nr": {"supports_reasoning": False},
},
))
The design details that matter:
- Keys are exact model IDs, and values are partial entries in the
model_overridesschema. Omitted fields stay unknown — they are not written asFalse. A partial entry patches catalog metadata for the fields it mentions without erasing the rest. - Precedence: an explicit user
model_overridesentry wins, then the plugin’s declaration, then catalog/built-in data. The plugin supplies the default; the user can always override it inconfig.yaml(the full field list is in the model_overrides guide). - There is exactly one read seam.
agent/models_dev.py::_apply_overridesmerges the declaration, and the picker badges, context lookup, image routing and/api/model/infoall read through it. You declare once instead of guessing which surface reads what.
Know the boundaries too, so you do not expect more than you get: the picker’s fast badge is still a name heuristic and does not consult this table, and reasoning-effort tiers are gated by the provider/model vocabulary rather than by capabilities.
2. Usage: make /usage aware of your provider
The second gap is quota. fetch_account_usage used to dispatch only through the closed _USAGE_FETCHERS name table — a plugin profile could load and infer, yet /usage had nothing to render.
Now the plugin overrides the hook:
from datetime import datetime, timezone
from agent.account_usage import AccountUsageSnapshot, AccountUsageWindow
def fetch_account_usage(self, *, api_key=None, base_url=None):
return AccountUsageSnapshot(
provider=self.name,
source="my_provider_api",
fetched_at=datetime.now(timezone.utc),
windows=(AccountUsageWindow(label="Monthly", used_percent=39),),
)
Three rules come with it: do not format output yourself (rendering is core’s job), built-ins stay authoritative (a plugin with the same name never displaces openai-codex, anthropic or openrouter), and failure is silent (returning None or raising is treated as “no usage available” and never breaks /usage).
One hook lights up four surfaces, because each of them passes the active provider id straight into the same function instead of keeping its own table:
| Surface | Where you see it |
|---|---|
| CLI | hermes usage --provider acme |
| CLI / TUI slash command | /usage (10 s bounded) |
| Messaging gateway | sending /usage in Telegram or Discord |
| TUI / Desktop | the session usage feed (session.usage RPC) |
With a snapshot returned, the CLI renders lines like these:
📈 Account limits
Provider: acme
Monthly credits: 61% remaining (39% used) • resets in 10d 22h
Extra usage: 0/2,500 credits
There is already an in-tree example: plugins/model-providers/opencode-zen/ reads OpenCode Go plan windows through this hook, with no core table entry added for it.
3. Login: declarative OAuth PKCE instead of a hand-written browser flow
The third gap is the most expensive one to close by hand: making your provider support OAuth usually means writing the browser open, a loopback listener, callback validation, token persistence and refresh yourself.
If your IdP speaks standard OAuth 2.0 Authorization Code + PKCE, you now declare it:
from hermes_cli.auth_oauth_pkce_plugin import (
OAuthPKCEConfig, pkce_auth_handler, pkce_refresh_credential,
)
from providers import register_provider
from providers.base import ProviderProfile
cfg = OAuthPKCEConfig(
client_id="hermes-public-client", # public client — PKCE is the proof, no secret
authorize_url="https://auth.example.com/oauth/authorize",
token_url="https://auth.example.com/oauth/token",
scopes=("inference", "offline_access"),
redirect_port=0, # 0 = OS-assigned; pin it if the IdP allowlists the URI
)
register_provider(ProviderProfile(
name="example-pkce",
auth_type="oauth_external",
base_url="https://api.example.com/v1",
auth_handler=pkce_auth_handler(cfg),
refresh_credential=pkce_refresh_credential(cfg),
))
Core owns the whole lifecycle from there: hermes auth add example-pkce [--no-browser] opens the browser (or prints the URL with an SSH-tunnel hint on a remote box), listens on http://127.0.0.1:<port>/callback, checks the CSRF state, exchanges the code with S256 PKCE, and stores the grant as a pooled credential (auth_type=oauth, source=manual:loopback_pkce). auth status reports logged in / expired, auth logout removes the rows, and auth refresh plus the 401 recovery path rotate through the refresh-token grant — re-reading auth.json under the auth lock first, so a rotation another process already performed is adopted instead of spending a single-use refresh token twice.
The security boundary is enforced before any request, on login and on refresh alike: both endpoints must be https:// (plain http:// is accepted only for a loopback literal, for a local development IdP); the token_url host must be the authorize_url host, a subdomain of it, or listed in allowed_hosts; the listener binds the literal 127.0.0.1; and tokens, state and the PKCE verifier are never logged.
Can I use this today?
Honestly: not from a release. All three changes merged on 20 September 2026, and the newest release is v0.21.3 (tag v2026.9.14). They are on main and in no release yet, so hermes update still gives you the old behaviour. If you want the reference, the website/docs/developer-guide/model-provider-plugin.md page on main now carries “Declaring model capabilities”, “Account usage” and “Declarative OAuth (PKCE) for plugins”.
Once your plugin is written, there is a quick way to see whether it actually loaded:
hermes doctor # an auth_type="api_key" profile shows up under Provider Connectivity with a /models probe
The pattern
All three hooks come from one idea: core provides the seam, vendor specifics live in the plugin. Capabilities are declared (not scattered booleans), usage is a hook that returns a snapshot (not four surfaces each formatting differently), and login is one OAuth config (not every plugin writing its own browser flow). If you are building a provider plugin, two existing write-ups pair well with this one: installing provider plugins through pip entry points and the newest provider batch (Ramp, Nebius, Tencent); for ready-made plugins to study, see the plugin catalog.