LLM Providers
職務
「provider 名(nvidia、kimi、openai、openrouter…)」を「具体的な hostname、メッセージ前処理、extra_body、モデルリスト」に翻訳する。Hermes は継承体系で provider 抽象をするのではなく、ProviderProfile データクラス + 一群のフックを用い、profile をプラグイン (plugin) として登録する。「宣言即ち接入」を実現する。Nous Portal、OpenRouter、OpenAI とカスタム endpoint をサポート。
設計動機
なぜ class OpenAIProvider(BaseProvider) ではなく ProviderProfile データクラス + フックにするのか? hermes は 30+ の provider を接入しており、大多数の違いは base_url、auth header、いくつかの特殊フィールドだけだ。継承体系は各サブクラスに大量のメソッド override を強要し、90% のサブクラスは空実装になる。dataclass + フックなら「7 個のフィールドを宣言 + 1 個のフックを override」するだけで接入が済み、複雑なロジック(Kimi の reasoning フィールドの位置など)は後からフックを書き直せばいい。
profile を providers/<name>.py ではなく plugins/model-providers/<name>/ に置く理由:プラグインディレクトリは plugin.yaml マニフェストとサブモジュールを持ち、ユーザーの $HERMES_HOME/plugins/ の同構造が同じ名前で上書き(last-writer-wins)する。リポジトリを fork せずに組み込み provider にパッチを当てられる。providers/*.py の単一ファイルは後方互換の遺物だ。
主要ファイル
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 だが、Xiaomi 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()はPython-urllib/<ver>ではなくhermes-cli/<version>を返す。一部の provider 前段に WAF があり、デフォルト UA だと 403 を食らう。モジュールドキュメント: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 単一ファイルの三箇所を走査する。alias 表により kimi / moonshot などの同義名が同じ profile に向く。
get_provider_profile:65-76— name または alias で照合。list_providers:76-91— 全 profile を一覧。plugins/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を返す。主ループは generic chat_completions provider にフォールバック (fallback) し、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 で回避し、失敗時はfallback_modelsにフォールバックする——ここに置くべきは agentic モデルだけで、non-tool-calling モデルを置くと picker が誤選する。 - user plugin による bundled 上書き:
register_providerは last-writer-wins で、user plugin は bundled の後に同名で上書きする。legacy のproviders/<name>.pyも最後に import されれば上書きできる——footgun なので、新規コードはプラグインディレクトリを使う。
まとめ
Provider 抽象のキーワードは「profile + フック」であって「継承」ではない。新規 provider は plugins/model-providers/ にサブディレクトリを作り、ProviderProfile を宣言して register_provider するだけでよく、base を修正する必要はない。遅延発見が起動を速く保ち、必要に応じてロードする。