LLM Providers
职责
把「provider 名(nvidia、kimi、openai、openrouter…)」翻译成「具体的 hostname、消息预处理、extra_body、模型列表」。Hermes 不用继承体系做 provider 抽象,而是用 ProviderProfile 数据类 + 一组钩子,profile 作为插件注册,实现「声明即接入」。支持 Nous Portal、OpenRouter、OpenAI 及自定义 endpoint。
设计动机
为什么用 ProviderProfile 数据类 + 钩子,而非 class OpenAIProvider(BaseProvider)?hermes 接入 30+ provider,绝大多数差异只是 base_url、auth header、几个特殊字段。继承体系迫使每个子类 override 一堆方法,90% 的子类只是空实现。dataclass + 钩子让「声明 7 个字段 + override 1 个钩子」就完成接入,复杂逻辑(如 Kimi 的 reasoning 字段位置)再重写钩子。
profile 放 plugins/model-providers/<name>/ 而非 providers/<name>.py:插件目录带 plugin.yaml 清单、子模块,被 user $HERMES_HOME/plugins/ 同结构覆盖(last-writer-wins),不用 fork 仓库就能 patch 内置 provider。providers/*.py 单文件是 back-compat 遗留。
关键文件
OMIT_TEMPERATURE:21— 哨兵对象,表示「不发送 temperature」(Kimi 让 server 管):@dataclass ProviderProfile:38-88— provider 元数据(name、aliases、base_url、auth、temperature 策略…):
OMIT_TEMPERATURE = object()
@dataclass
class ProviderProfile:
name: str
api_mode: str = "chat_completions"
aliases: tuple = ()
env_vars: tuple = ()
base_url: str = ""
auth_type: str = "api_key"
supports_vision: bool = False
supports_vision_tool_messages: bool = True
fallback_models: tuple = ()
fixed_temperature: Any = Nonefixed_temperature 三态:None 用 caller 默认、OMIT_TEMPERATURE 不发该字段、具体数值则覆盖。supports_vision_tool_messages 默认 True,小米 MiMo 接受多模态 user message 却拒绝 list-type tool content,要设 False,否则 400 "text is not set"。
get_hostname / prepare_messages:98-118— 钩子:请求走哪个主机、消息怎么预处理build_extra_body:119-175— 钩子:provider 专属请求体字段:
def build_extra_body(self, *, session_id: str | None = None, **context: Any) -> dict[str, Any]:
"""Merged into the API kwargs extra_body. Default: empty dict."""
return {}
def build_api_kwargs_extras(self, *, reasoning_config=None, **context) -> tuple[dict, dict]:
"""Returns (extra_body_additions, top_level_kwargs).
OpenRouter: reasoning in extra_body. Kimi: reasoning_effort is top-level."""
return {}, {}build_extra_body 默认空 dict,子类按需 override。build_api_kwargs_extras 把字段拆成 extra_body 与 top-level api_kwargs 两路:OpenRouter reasoning 放 extra_body,Kimi reasoning_effort 是 top-level。
fetch_models:175-232— 钩子:拉取该 provider 可用模型列表。_profile_user_agent()返回hermes-cli/<version>而非Python-urllib/<ver>,某些 provider 前有 WAF 会 403 默认 UA。模块文档:9-28— 描述懒发现机制(首次调用才扫描插件)register_provider:53-65— 注册一个 profile:
_REGISTRY: dict[str, ProviderProfile] = {}
_ALIASES: dict[str, str] = {}
def register_provider(profile: ProviderProfile) -> None:
"""Later registrations with the same name replace earlier ones —
user plugins can override bundled profiles without editing repo code."""
_REGISTRY[profile.name] = profile
for alias in profile.aliases:
_ALIASES[alias] = profile.name
def get_provider_profile(name: str) -> ProviderProfile | None:
if not _discovered:
_discover_providers()
canonical = _ALIASES.get(name, name)
return _REGISTRY.get(canonical)懒发现:第一次 get_provider_profile 才触发 _discover_providers(),扫描 bundled + user plugins + legacy single-file 三处。alias 表让 kimi / moonshot 等同义名指向同一 profile。
get_provider_profile:65-76— 按 name 或 alias 查询list_providers:76-91— 列出全部 profileplugins/model-providers/ 目录— 实际 profile 定义(每个 provider 一个子目录)
数据流
init_agent:276拿到 provider 名,调get_provider_profile:65(首次触发懒发现:扫描plugins/model-providers/<name>/,导入并register_provider)。- 拿到
ProviderProfile后:get_hostname(providers/base.py:98)定主机、prepare_messages(providers/base.py:111)预处理api_messages、build_extra_body(providers/base.py:119)加专属字段、temperature 按策略(None / OMIT_TEMPERATURE / 具体值)。 - 主循环用组装好的请求调用 provider API(
重试子循环:1227)。 - 自定义 provider 的 extra_body 合并在
_merge_custom_provider_extra_body:257完成。
边界与失败
- profile 不存在:配置写了不认识的 provider 名,
get_provider_profile返回None,主循环 fall back 到 generic chat_completions provider,picker 拿不到 fallback_models 只显示空。 - 不支持 vision:
supports_vision=False时 tool_result 图片不被原样塞进 tool message;supports_vision_tool_messages=False时走 list-type content 会 400,得把图片降级或转 base64。 - fetch_models 失败:某些 provider 的
/models端点有 WAF(默认Python-urllibUA 被 403)。fetch_models用hermes-cli/<version>UA 规避,失败 fall back 到fallback_models——只 agentic 模型应在这里,非 tool-calling 会导致 picker 误选。 - user plugin 覆盖 bundled:
register_provider是 last-writer-wins,user plugin 在 bundled 之后同名覆盖。legacyproviders/<name>.py最后导入也能覆盖——footgun,新代码走插件目录。
小结
Provider 抽象的关键词是「profile + 钩子」而非「继承」。新增 provider 只需在 plugins/model-providers/ 建个子目录、声明一个 ProviderProfile 并 register_provider,无需改 base。懒发现保证启动快、按需加载。