Você escreveu um plugin de provider para o seu próprio gateway. Por que o Hermes ainda o trata como cidadão de segunda classe?


Você escreveu um plugin de provider do Hermes para o gateway de inferência da sua empresa. Ele aparece no hermes model, responde, e seus colegas instalaram a partir do seu README. Aí começam os cortes de papel: o picker do /model nunca mostra o selo de reasoning para ele; você cola um print de um diagrama de arquitetura e o modelo responde como se não houvesse imagem; o /usage fica permanentemente em branco, então você mantém uma aba do navegador aberta ao lado; e um colega novo ainda precisa copiar um token do chat para o .env.

Quatro lacunas, uma causa só: o core procurava tudo no catálogo público models.dev, e o seu modelo não está nesse catálogo. Três hooks mesclados em 20 de setembro (#116570, #116555, #116576 — todos parte da onda #116408) devolvem três tarefas ao plugin: declarar capacidades, reportar uso e executar o login.

1. Capacidades do modelo: declare uma vez e todo consumidor acredita em você

Comece pela causa raiz. get_model_capabilities resolvia a partir de exatamente dois lugares — o catálogo models.dev e os model_overrides do usuário no config.yaml. Nenhum dos dois conhece os modelos de um provider fora da árvore, então “desconhecido” era tratado como “não suportado” adiante: o roteamento de imagem decidia text (mandando seu print como texto), o picker pulava o selo, e o comprimento de contexto voltava vazio.

Um plugin agora pode declarar isso no 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,   # o tier de raciocínio é fixado pelo id do modelo
            "supports_vision": True,
            "supports_tools": True,
            "context_window": 200000,
            "model_family": "acme",
        },
        "acme-nr": {"supports_reasoning": False},
    },
))

Os detalhes de design que importam:

  • As chaves são IDs exatos de modelo, e os valores são entradas parciais no schema de model_overrides. Campos omitidos continuam desconhecidos — não são gravados como False. Uma entrada parcial faz patch nos metadados do catálogo apenas nos campos que menciona, sem apagar o resto.
  • Precedência: uma entrada explícita em model_overrides do usuário vence, depois a declaração do plugin, depois os dados do catálogo/built-in. O plugin fornece o padrão; o usuário sempre pode sobrescrever no config.yaml (a lista completa de campos está em o guia de model_overrides).
  • Existe exatamente uma costura de leitura. agent/models_dev.py::_apply_overrides faz o merge da declaração, e os selos do picker, a busca de contexto, o roteamento de imagem e o /api/model/info leem todos por ali. Você declara uma vez em vez de adivinhar qual superfície lê o quê.

Conheça também os limites, para não esperar mais do que você recebe: o selo fast do picker ainda é uma heurística de nome e não consulta essa tabela, e os tiers de reasoning effort são controlados pelo vocabulário de provider/modelo, não pelas capacidades.

2. Uso: faça o /usage conhecer o seu provider

A segunda lacuna é cota. fetch_account_usage só despachava pela tabela fechada de nomes _USAGE_FETCHERS — um profile de plugin conseguia carregar e inferir, mas o /usage não tinha o que renderizar.

Agora o plugin sobrescreve o 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),),
    )

Vêm três regras junto: não formate a saída você mesmo (renderizar é trabalho do core), os built-ins continuam autoritativos (um plugin com o mesmo nome nunca desloca openai-codex, anthropic ou openrouter), e falhar é silencioso (retornar None ou lançar exceção é tratado como “sem uso disponível” e nunca quebra o /usage).

Um hook acende quatro superfícies, porque todas passam o id do provider ativo direto para a mesma função, em vez de manter cada uma a sua própria tabela:

Superfície Onde você vê
CLI hermes usage --provider acme
Comando slash da CLI / TUI /usage (limite de 10 s)
Gateway de mensagens enviar /usage no Telegram ou no Discord
TUI / Desktop o feed de uso da sessão (RPC session.usage)

Com um snapshot retornado, a CLI renderiza linhas como estas:

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

Já existe um exemplo dentro da árvore: plugins/model-providers/opencode-zen/ lê as janelas de plano do OpenCode Go por esse hook, sem nenhuma entrada de catálogo adicionada no core para ele.

3. Login: OAuth PKCE declarativo em vez de um fluxo de navegador escrito à mão

A terceira lacuna é a mais cara de fechar na mão: fazer seu provider suportar OAuth normalmente significa escrever você mesmo a abertura do navegador, o listener de loopback, a validação de callback, a persistência de token e o refresh.

Se o seu IdP fala OAuth 2.0 Authorization Code + PKCE padrão, agora você declara:

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",     # cliente público — o PKCE é a prova, sem 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 = atribuído pelo SO; fixe se o IdP exigir a URI na lista
)

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

Daí em diante o core é dono do ciclo de vida inteiro: hermes auth add example-pkce [--no-browser] abre o navegador (ou imprime a URL com uma dica de túnel SSH numa máquina remota), escuta em http://127.0.0.1:<port>/callback, confere o state do CSRF, troca o código com PKCE S256 e guarda o grant como credencial em pool (auth_type=oauth, source=manual:loopback_pkce). O auth status reporta logado / expirado, o auth logout remove as linhas, e o auth refresh mais o caminho de recuperação de 401 giram pelo refresh-token grant — relendo o auth.json sob o lock de auth primeiro, para que uma rotação já feita por outro processo seja adotada em vez de gastar duas vezes um refresh token de uso único.

A fronteira de segurança é aplicada antes de qualquer requisição, tanto no login quanto no refresh: os dois endpoints precisam ser https:// (http:// simples só é aceito para um literal de loopback, para um IdP de desenvolvimento local); o host do token_url precisa ser o host do authorize_url, um subdomínio dele, ou estar listado em allowed_hosts; o listener faz bind no literal 127.0.0.1; e tokens, state e o verificador PKCE nunca são logados.

Posso usar isso hoje?

Sendo honesto: não a partir de uma release. As três mudanças foram mescladas em 20 de setembro de 2026, e a release mais nova é a v0.21.3 (tag v2026.9.14). Elas estão na main e em nenhuma release, então hermes update ainda te dá o comportamento antigo. Se você quiser a referência, a página website/docs/developer-guide/model-provider-plugin.md na main agora traz “Declaring model capabilities”, “Account usage” e “Declarative OAuth (PKCE) for plugins”.

Depois que o seu plugin estiver escrito, há um jeito rápido de ver se ele realmente carregou:

hermes doctor     # um perfil com auth_type="api_key" aparece em Provider Connectivity com uma sondagem em /models

O padrão

Os três hooks vêm de uma ideia só: o core fornece a costura, as especificidades do fornecedor vivem no plugin. Capacidades são declaradas (não booleans espalhados), uso é um hook que retorna um snapshot (não quatro superfícies formatando cada uma do seu jeito), e login é uma configuração de OAuth (não cada plugin escrevendo o seu próprio fluxo de navegador). Se você está construindo um plugin de provider, dois textos existentes combinam bem com este: instalar provider plugins via entry points do pip e o lote mais novo de providers (Ramp, Nebius, Tencent); para plugins prontos para estudar, veja o catálogo de plugins.