给自家推理网关写插件,为什么它在 Hermes 里总像「二等公民」


你给公司内部的推理网关写了一个 Hermes provider 插件:hermes model 里能选到它,能正常出结果,同事也照着文档装上了。然后问题开始一个个冒出来 —— /model 选择器里它没有那个「推理」徽章;你把一张架构截图贴进对话,模型却像没看见图;/usage 里永远是空白,想知道还剩多少额度只能另开一个后台页面;新同事要登录,还是得把 token 从聊天记录里复制到 .env。

四个缺口,其实是同一个原因:核心只从 models.dev 的公开目录里查信息,而你的模型不在那本目录里。 9 月 20 日合并的三个钩子(#116570、#116555、#116576)把三件事交回给插件:能力声明、用量上报、登录流程。它们都在 #116408 这一波里。

一、模型能力:声明一次,所有消费者都认

先看根因。能力查询 get_model_capabilities 只从两个地方解析:models.dev 目录和用户在 config.yaml 里的 model_overrides。这两处都不知道你那个 out-of-tree provider 的模型,于是「未知」被下游当成「不支持」:图片路由判断为 text(把图当文本递过去),选择器不给徽章,上下文长度查不到。

现在插件可以在 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,   # 推理档位由模型 ID 固定
            "supports_vision": True,
            "supports_tools": True,
            "context_window": 200000,
            "model_family": "acme",
        },
        "acme-nr": {"supports_reasoning": False},
    },
))

几个关键设计:

  • 键是精确模型 ID,值是 model_overrides 里那套 schema 的局部条目。省略的字段保持「未知」,不是被写成 False —— 局部条目只覆盖它说到的字段,不会把目录里已知的信息抹掉。
  • 优先级:用户显式写的 model_overrides > 插件声明 > 目录/内置数据。也就是说插件提供默认值,用户随时可以在 config.yaml 里覆盖回来(model_overrides 那篇里有完整字段表)。
  • 只有一个读取缝:agent/models_dev.py::_apply_overrides 负责合并,选择器徽章、上下文长度查询、图片路由、/api/model/info 全部走这条缝读。所以插件作者只需要声明一次,不用去猜每个界面各自读哪里。

边界也要说清楚,避免你期待过多:选择器里的 fast 徽章仍然由模型名启发式决定,不在能力表里;reasoning effort 档位由 provider/模型词汇表控制,也不读这张表。

二、用量:让 /usage 认识你的 provider

第二个缺口是额度。以前 fetch_account_usage 只按内置的 _USAGE_FETCHERS 名字表分发 —— 插件 profile 能加载、能推理,但 /usage 永远渲染不出东西。

现在插件可以覆写钩子:

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

约定有三条:不要自己格式化输出(渲染是核心的事);内置 provider 优先(openai-codex、anthropic、openrouter 不会因为有个同名插件就被顶掉);失败即静默(返回 None 或抛异常都当作「没有用量」处理,不会打断 /usage)。

这个钩子一动,四个表面同时亮起来 —— 它们都直接把当前 provider id 传进同一个函数,没有各自维护一张表:

表面 你会在哪里看到
CLI hermes usage --provider acme
CLI / TUI 斜杠命令 /usage(10 秒上限)
消息网关 在 Telegram/Discord 里发 /usage
TUI / Desktop 会话用量面板(session.usage RPC)

插件返回快照后,命令行会渲染成这样的几行:

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

一个已经落地的例子是仓库里的 plugins/model-providers/opencode-zen/:它通过这个钩子读取 OpenCode Go 的套餐窗口,核心代码里没有为它加任何表项。

三、登录:声明式 OAuth PKCE,不用自己写浏览器流程

第三个缺口最消耗人:让你的 provider 支持 OAuth 登录,通常意味着自己写浏览器打开、本机回环监听、回调校验、token 落盘、过期刷新。

如果你的 IdP 说标准的 OAuth 2.0 Authorization Code + PKCE,现在只要声明配置:

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",     # 公开客户端,PKCE 就是证明,不需要 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 = 由系统分配端口;IdP 允许清单要求固定就写死
)

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

之后整条生命周期由核心接管:hermes auth add example-pkce [--no-browser] 打开浏览器(远程机器上改为打印 URL 并提示 SSH 隧道)、在 http://127.0.0.1:<port>/callback 上监听、校验 CSRF state、用 S256 PKCE 换 code,并把授权存成池化凭据(auth_type=oauth、source=manual:loopback_pkce)。auth status 报告登录/过期,auth logout 清行,auth refresh 和 401 恢复路径都会用 refresh token 轮换——而且在轮换前会先在认证锁下重读 auth.json,如果同行已被别的进程轮换过就直接采纳,避免把一次性 refresh token 花两次。

安全边界在任何请求之前执行,登录和刷新都一样:两端点必须 https://(明文 http:// 只允许回环字面地址,供本地开发 IdP 使用);token_url 的主机必须是 authorize_url 的主机、其子域,或在 allowed_hosts 里;监听只绑定字面 127.0.0.1;token、state 和 PKCE verifier 永不写日志。

这些现在能用吗

要如实说明:三个改动都在 2026 年 9 月 20 日合并,而最新发版是 v0.21.3(标签 v2026.9.14)。它们在 main 上,尚未进入任何正式版本,hermes update 拿到的还是旧行为。想读原始文档可以看仓库里 website/docs/developer-guide/model-provider-plugin.md 的「Declaring model capabilities」「Account usage」「Declarative OAuth (PKCE) for plugins」三节(同样在 main 上)。

写完插件后,验证它有没有被加载有个现成办法:

hermes doctor     # auth_type="api_key" 的 profile 会出现在 Provider Connectivity 一节,带 /models 探测

小结

这三个钩子背后是同一条原则:核心提供接缝,厂商细节留在插件里。 能力用声明(不是散落在各处的布尔标志),用量用一个返回快照的钩子(不是让每个界面各写一份格式化),登录用一份 OAuth 配置(不是让每个插件自己写浏览器流程)。如果你正在做 provider 插件,可以从已有的两个参考开始:pip 入口点安装 provider 插件、新一批 provider(Ramp / Nebius / 腾讯);想找现成的插件可看插件目录。