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 comoFalse. 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_overridesdo 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 noconfig.yaml(a lista completa de campos está em o guia de model_overrides). - Existe exatamente uma costura de leitura.
agent/models_dev.py::_apply_overridesfaz o merge da declaração, e os selos do picker, a busca de contexto, o roteamento de imagem e o/api/model/infoleem 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.