Du hast ein Provider-Plugin für dein eigenes Gateway geschrieben. Warum behandelt Hermes es trotzdem wie einen Bürger zweiter Klasse?


Du hast ein Hermes-Provider-Plugin für das Inference-Gateway deiner Firma geschrieben. Es taucht in hermes model auf, es antwortet, deine Kollegen haben es nach deiner README installiert. Dann beginnen die Papierschnitte: Der /model-Picker zeigt für dein Plugin nie das Reasoning-Badge; du fügst einen Screenshot eines Architekturdiagramms ein und das Modell antwortet, als wäre kein Bild da; /usage bleibt dauerhaft leer, also lässt du daneben einen Browser-Tab offen; und ein neuer Kollege muss den Token immer noch aus dem Chat in die .env kopieren.

Vier Lücken, eine Ursache: Der Core schlug alles im öffentlichen models.dev-Katalog nach, und dein Modell steht nicht in diesem Katalog. Drei am 20. September gemergte Hooks (#116570, #116555, #116576 — alle Teil der #116408-Welle) geben drei Aufgaben zurück ans Plugin: Capabilities deklarieren, Usage melden und den Login durchführen.

1. Modell-Capabilities: einmal deklarieren, jeder Consumer glaubt dir

Fangen wir bei der Grundursache an. get_model_capabilities löste aus genau zwei Quellen auf — dem models.dev-Katalog und den model_overrides des Nutzers in config.yaml. Keine von beiden kennt die Modelle eines Out-of-Tree-Providers, also wurde „unbekannt“ downstream als „nicht unterstützt“ behandelt: Das Image-Routing entschied text (dein Screenshot wurde als Text durchgereicht), der Picker ließ das Badge weg, und die Kontextlänge kam leer zurück.

Ein Plugin kann das jetzt im Profile deklarieren:

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,   # die Reasoning-Stufe ist über die Modell-ID festgelegt
            "supports_vision": True,
            "supports_tools": True,
            "context_window": 200000,
            "model_family": "acme",
        },
        "acme-nr": {"supports_reasoning": False},
    },
))

Die Designdetails, auf die es ankommt:

  • Keys sind exakte Modell-IDs, und die Werte sind partielle Einträge im model_overrides-Schema. Weggelassene Felder bleiben unbekannt — sie werden nicht als False geschrieben. Ein partieller Eintrag patcht die Katalog-Metadaten nur für die Felder, die er nennt, ohne den Rest zu löschen.
  • Vorrang: Ein expliziter model_overrides-Eintrag des Nutzers gewinnt, dann die Deklaration des Plugins, dann Katalog- und Built-in-Daten. Das Plugin liefert den Default; der Nutzer kann ihn in config.yaml jederzeit überschreiben (die vollständige Feldliste steht im model_overrides-Leitfaden).
  • Es gibt genau eine Lesenaht. agent/models_dev.py::_apply_overrides führt die Deklaration zusammen, und Picker-Badges, Context-Lookup, Image-Routing und /api/model/info lesen alle darüber. Du deklarierst einmal, statt zu raten, welche Oberfläche was liest.

Kenne auch die Grenzen, damit du nicht mehr erwartest, als du bekommst: Das fast-Badge des Pickers ist weiterhin eine Namensheuristik und schaut nicht in diese Tabelle, und Reasoning-Effort-Stufen werden über das Provider- und Modell-Vokabular gesteuert, nicht über Capabilities.

2. Usage: bring /usage bei, deinen Provider zu kennen

Die zweite Lücke ist das Kontingent. fetch_account_usage dispatchte bisher nur über die geschlossene Namenstabelle _USAGE_FETCHERS — ein Plugin-Profil konnte laden und inferieren, doch /usage hatte nichts zu rendern.

Jetzt überschreibt das Plugin den 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),),
    )

Drei Regeln kommen mit: Formatiere die Ausgabe nicht selbst (Rendering ist Aufgabe des Cores), Built-ins bleiben maßgeblich (ein Plugin mit demselben Namen verdrängt nie openai-codex, anthropic oder openrouter), und Fehler sind still (ein None oder eine Exception gilt als „keine Usage verfügbar“ und bricht /usage nie).

Ein Hook beleuchtet vier Oberflächen, denn jede von ihnen reicht die ID des aktiven Providers direkt in dieselbe Funktion, statt eine eigene Tabelle zu pflegen:

Oberfläche Wo du sie siehst
CLI hermes usage --provider acme
CLI-/TUI-Slash-Command /usage (10 s begrenzt)
Messaging-Gateway /usage in Telegram oder Discord senden
TUI / Desktop der Session-Usage-Feed (session.usage RPC)

Wenn ein Snapshot zurückkommt, rendert die CLI Zeilen wie diese:

📈 Account limits
Provider: acme
Monthly credits: 61% remaining (39% used) • resets in 10d 22h
Extra usage: 0/2,500 credits

Es gibt bereits ein Beispiel im Tree: plugins/model-providers/opencode-zen/ liest die Plan-Fenster von OpenCode Go über diesen Hook, ohne dass dafür ein Core-Tabelleneintrag ergänzt wurde.

3. Login: deklaratives OAuth PKCE statt handgeschriebenem Browser-Flow

Die dritte Lücke ist die teuerste, wenn man sie von Hand schließt: OAuth für deinen Provider bedeutet normalerweise, Browser-Öffnen, Loopback-Listener, Callback-Validierung, Token-Persistenz und Refresh selbst zu schreiben.

Wenn dein IdP standardmäßiges OAuth 2.0 Authorization Code + PKCE spricht, deklarierst du es jetzt:

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 ist der Nachweis, kein Secret nötig
    authorize_url="https://auth.example.com/oauth/authorize",
    token_url="https://auth.example.com/oauth/token",
    scopes=("inference", "offline_access"),
    redirect_port=0,                      # 0 = vom OS zugewiesen; festnageln, wenn der IdP die URI allowlistet
)

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),
))

Ab da besitzt der Core den gesamten Lifecycle: hermes auth add example-pkce [--no-browser] öffnet den Browser (oder gibt auf einer Remote-Kiste die URL mit SSH-Tunnel-Hinweis aus), lauscht auf http://127.0.0.1:<port>/callback, prüft den CSRF-state, tauscht den Code mit S256 PKCE ein und speichert den Grant als gepoolte Credential (auth_type=oauth, source=manual:loopback_pkce). auth status meldet eingeloggt / abgelaufen, auth logout entfernt die Zeilen, und auth refresh plus der 401-Recovery-Pfad rotieren über den Refresh-Token-Grant — dabei wird auth.json unter dem Auth-Lock zuerst neu gelesen, damit eine Rotation, die ein anderer Prozess bereits ausgeführt hat, übernommen wird, statt einen Single-Use-Refresh-Token zweimal zu verbrauchen.

Die Sicherheitsgrenze wird vor jeder Anfrage durchgesetzt, beim Login wie beim Refresh: Beide Endpunkte müssen https:// sein (reines http:// wird nur für ein Loopback-Literal akzeptiert, für einen lokalen Entwicklungs-IdP); der Host der token_url muss der Host der authorize_url, eine Subdomain davon oder in allowed_hosts gelistet sein; der Listener bindet das Literal 127.0.0.1; und Tokens, state und der PKCE-Verifier werden nie geloggt.

Kann ich das heute nutzen?

Ehrlich gesagt: nicht aus einem Release. Alle drei Änderungen wurden am 20. September 2026 gemergt, und der neueste Release ist v0.21.3 (Tag v2026.9.14). Sie sind nur auf main und in keinem Release, hermes update liefert dir also weiterhin das alte Verhalten. Wenn du die Referenz willst: Die Seite website/docs/developer-guide/model-provider-plugin.md auf main enthält jetzt die Abschnitte „Declaring model capabilities“, „Account usage“ und „Declarative OAuth (PKCE) for plugins“.

Wenn dein Plugin geschrieben ist, gibt es einen schnellen Weg zu sehen, ob es wirklich geladen wurde:

hermes doctor     # ein Profil mit auth_type="api_key" erscheint unter Provider Connectivity mit einer /models-Probe

Das Muster

Alle drei Hooks folgen einer Idee: Der Core liefert die Naht, die Vendor-Spezifika leben im Plugin. Capabilities werden deklariert (statt verstreuter Booleans), Usage ist ein Hook, der einen Snapshot zurückgibt (statt vier Oberflächen, die jeweils anders formatieren), und Login ist eine OAuth-Konfiguration (statt eines eigenen Browser-Flows in jedem Plugin). Wenn du ein Provider-Plugin baust, passen zwei bestehende Beiträge gut dazu: Provider-Plugins über pip-Entry-Points installieren und die neueste Provider-Charge (Ramp, Nebius, Tencent); fertige Plugins zum Studieren findest du im Plugin-Katalog.