LLM Providers
職責
把「provider 名(nvidia、kimi、openai、openrouter…)」翻譯成「具體的 hostname、訊息預處理、extra_body、模型列表」。Hermes 不用繼承體系做 provider 抽象,而是用 ProviderProfile 資料類別 + 一組鉤子,profile 作為外掛 (plugin) 註冊,實作「宣告即接入」。支援 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 管):
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)預處理build_turn_context:268產出的api_messagesbuild_extra_body(providers/base.py:119)加 provider 專屬欄位- temperature 按 profile 策略(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。懶發現保證啟動快、按需載入。