Hermes Agent 源码专题【左扬精讲】—— Provider 运行时解析:凭证与多后端支持

Hermes Agent 源码专题【左扬精讲】—— Provider 运行时解析:凭证与多后端支持

本文聚焦 Hermes Agent 在 运行时 如何解析模型 Provider:插件如何被发现与注册、api_mode 如何决定、api_key/base_url 如何流动、凭证池如何轮换、多后端如何隔离。这是从 AIAgent 的构造到 OpenAI/Anthropic/Copilot/Qwen/Custom 五种原生/类原生协议全过程的细节。

providers/__init__.py					← Provider 插件注册表(_REGISTRY / _ALIASES / _discover_providers)
providers/base.py						← ProviderProfile dataclass:auth、endpoint、client/request 级别 quirks
plugins/model-providers/<name>/__init__.py	← 每个内置 provider 的插件入口(register_provider 调用点)
hermes_cli/providers.py				    ← resolve_provider_full / determine_api_mode 等运行时解析
hermes_cli/config.py					← DEFAULT_CONFIG.model 与 OPTIONAL_ENV_VARS 的自动注入
agent/credential_pool.py				← 多凭证轮换、池化失败回退(load_pool / CredentialPool)
agent/agent_runtime_helpers.py		    ← switch_model / 客户端重建的运行时切换入口

ProviderProfile api_mode 凭证池 OpenAI 兼容 OAuth 设备码 运行时切换

学习重点提示

必须掌握

  • 理解 ProviderProfile 的字段语义与子类扩展点
  • 理解 _discover_providers 的发现顺序(bundled → user → legacy)
  • 理解 determine_api_modeProviderProfile + URL 启发式下的优先级
  • 理解 CredentialPool 如何把单 key 升级为多 key 池并轮换

需要了解

  • 多后端(OpenAI / Anthropic / Copilot / Qwen / Custom)的协议差异
  • OPTIONAL_ENV_VARS 与 provider env_vars 的自动双向同步

目录

一、ProviderProfile:声明式 Provider 抽象

What — ProviderProfile 是什么?

ProviderProfile 是 Hermes 中描述一个模型 Provider 的 纯数据 dataclass。它把"鉴权方式、API endpoint、客户端级 quirk、请求级 quirk、目录拉取、消息预处理"等所有 Provider 相关的差异集中到一份声明里,让 AIAgent 不需要为每个 Provider 写一坨 if/else。

Why — 为什么需要声明式 ProviderProfile?

Hermes 同时要跑 OpenAI、Anthropic、Copilot、Qwen、X.AI、Bedrock、Kimi、GMI、本地 Ollama/vLLM 等 30+ 后端,每家都有自己的鉴权头、API 路径、reasoning 字段位置。如果 transport 在请求侧同时挂 20+ 布尔参数,函数签名爆炸,错配概率指数级上升。

没有 ProviderProfile 会发生什么?

  • build_api_kwargs 函数要写 20+ if 分支,OpenAI / Anthropic / Copilot 混在一处
  • 任何新增 Provider 都得改核心 transport,违反"核心是窄腰"原则
  • 运行时切换 provider 时,无法把"差异"作为参数传递,只能重建整个客户端

1.1 ProviderProfile 字段分组

providers/base.py 第 38-95 行,ProviderProfile 把字段分成 6 组:

源码视角 一 — ProviderProfile 的字段分组

providers/base.py 第 38-95 行:

@dataclass
class ProviderProfile:
      # Base provider profile — subclass or instantiate with overrides.

    # ── Identity(身份)─────────────────────────────────────────────
    name: str                                          # 规范名,如 "openrouter"
    api_mode: str = "chat_completions"                 # 默认 wire 协议
    aliases: tuple = ()                                # 兼容旧名/简写

    # ── Human-readable metadata(用户展示)───────────────────────────────
    display_name: str = ""                             # 显示名 "GMI Cloud"
    description: str = ""                              # 选择器副标题
    signup_url: str = ""                               # 引导用户注册的链接

    # ── Auth & endpoints(鉴权与端点)────────────────────────────────────
    env_vars: tuple = ()                               # 该 provider 期望读取的环境变量名
    base_url: str = ""                                 # 推理端点 base
    models_url: str = ""                               # 模型目录端点,缺省回退到 {base_url}/models
    auth_type: str = "api_key"                         # api_key/oauth_device_code/oauth_external/copilot/aws_sdk
    supports_health_check: bool = True                 # 是否参与 doctor 的 /models 探活

字段按"身份 / 用户展示 / 鉴权 / 视觉能力 / 模型目录 / 客户端/请求级别 quirk"分组,每个字段都对应一种 Provider 差异——把"哪些字段被使用、何时被使用"的决定权推到 subclass 或实例化方。

1.2 客户端级与请求级 quirk

ProviderProfile 把"一次性影响 client"和"每次请求都变"的差异拆成两类:

维度字段 / Hook影响范围例子
客户端级 default_headersfixed_temperature client 构造时一次性生效 GitHub Copilot 编辑器 UA、Anthropic x-api-key
请求级 build_extra_bodybuild_api_kwargs_extrasprepare_messages 每次 chat.completions.create 调用时合并 OpenRouter reasoning、Qwen vl_high_resolution_images、Custom 的 think
模型目录 fetch_modelsfallback_models model picker / fallback 决策 OpenRouter 公开目录、Anthropic x-api-key 鉴权目录
视觉能力 supports_visionsupports_vision_tool_messages 消息构造与 image_url 处理 MiMo 拒绝 list-typed tool content,需 supports_vision_tool_messages=False

关键设计:OMIT_TEMPERATURE sentinel

providers/base.py 第 21 行:

OMIT_TEMPERATURE = object()  # 不发送 temperature 字段(Kimi: 服务端管理)

用 sentinel 对象区分"不设置"和"设置为 None",因为 Kimi 等后端要求"完全不出现该字段"而不是"显式 None"。

1.3 Subclass 扩展:OpenRouter 的 reasoning 分流

不是所有 Provider 都是直白的 default_headers + base_url。OpenRouter 在 chat_completions 上同时承载 Anthropic / xAI / OpenAI / Google / DeepSeek 多种"上游",必须把"如何处理上游 reasoning"作为子类 hook 暴露出来。

源码视角 二 — OpenRouter 的 reasoning 分流

plugins/model-providers/openrouter/__init__.py 第 149-167 行:

if _anthropic_reasoning_is_mandatory(model):  # Claude 4.6+ 走 adaptive thinking
    cfg = reasoning_config or {}
    effort = cfg.get("effort")
    if cfg.get("enabled", True) is not False and effort and effort != "none":
        # 注意:放 top-level verbosity,不是 reasoning.effort
        # 因为 OpenRouter 对 4.6+ 的 reasoning.effort 是 ignored
        top_level["verbosity"] = effort
elif reasoning_config is not None:
    extra_body["reasoning"] = dict(reasoning_config)  # 其他模型:原样塞 extra_body
else:
    extra_body["reasoning"] = {"enabled": True, "effort": "medium"}  # 未设置时给默认值

if session_id and model and model.startswith(("x-ai/grok-", "xai/grok-")):
    # Grok 路由走 OpenRouter 时,固定 conv-id 让 xAI prompt cache 锁定后端
    extra_headers["x-grok-conv-id"] = session_id
if extra_headers:
    top_level["extra_headers"] = extra_headers
return extra_body, top_level

三个关键设计点:

  • 为何不在 reasoning 上设 enabled:false:OpenRouter 会把它转成 Anthropic thinking:{type:"disabled"},而 Claude 4.6+ 返回 HTTP 400。
  • effort 改路由到 verbosity:OpenRouter 把 reasoning.effort 在 Claude 4.6+ 上 ignored,但 verbosity 仍生效。
  • Grok x-grok-conv-id:用 session_id 钉住同一个 xAI 后端,让 prompt cache 不跨节点漂移。

1.4 Native Anthropic 的鉴权 quirk

并非所有 Provider 都用 Authorization: Bearer。Native Anthropic 必须用 x-api-key + anthropic-version 两个 header。看 plugins/model-providers/anthropic/__init__.py 第 16-40 行:

源码视角 三 — Anthropic fetch_models 的鉴权差异
def fetch_models(
    self,
    *,
    api_key: str | None = None,
    base_url: str | None = None,
    timeout: float = 8.0,
) -> list[str] | None:
      # Anthropic uses x-api-key header and anthropic-version.
    if not api_key:
        return None
    try:
        req = urllib.request.Request("https://api.anthropic.com/v1/models")
        req.add_header("x-api-key", api_key)                # 不是 Bearer
        req.add_header("anthropic-version", "2023-06-01")   # Anthropic API 版本头
        req.add_header("Accept", "application/json")
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            data = json.loads(resp.read().decode())
        return [
            m["id"]
            for m in data.get("data", [])
            if isinstance(m, dict) and "id" in m
        ]
    except Exception as exc:
        logger.debug("fetch_models(anthropic): %s", exc)
        return None

直接覆盖父类默认的 Bearer 鉴权逻辑;如果不显式重写,OpenAI SDK 默认会塞 Authorization: Bearer sk-...,Anthropic 端会 401。

本节小结

  • ProviderProfile 把 Provider 差异集中成一份 dataclass,transport 只读字段不写 if
  • 差异被分为客户端级 / 请求级 / 模型目录 / 视觉能力四组,每组对应一种扩展点
  • OMIT_TEMPERATURE sentinel 让"不发送"与"发送 None"在类型层即可区分
  • OpenRouter reasoning 在 Claude 4.6+ 上必须改走 verbosity,因为 OpenRouter 忽略了该后端的 reasoning.effort

二、插件发现:bundled → user → legacy 三段式

What — 插件发现是什么?

Hermes 把 Provider 当作"插件"管理。发现机制扫描三类来源:bundled(仓库自带)、user($HERMES_HOME 下用户安装)、legacy(providers/<name>.py 单文件)。每个来源 import 一次 plugin 的 __init__.py,触发 register_provider(profile) 调用,把 profile 装入 _REGISTRY

Why — 为什么用插件目录而非 if/else 大表?

Hermes 已 shipped 30+ provider,并且第三方可以无侵入地加自家后端(企业内 LLM、专有云、合规网关)。如果用 if/else,新增 provider 必须改核心 transport;而插件目录让任何 Python 进程都能 pip install hermes-provider-xxx 或把单文件夹丢到 ~/.hermes/plugins/model-providers/xxx/

没有插件目录会发生什么?

  • 每加一家企业 LLM 都要 fork Hermes 仓库、发 PR、走 review
  • 用户私有 profile 与核心 PR 搅在一起,PR 噪音指数级上升
  • 第三方在 hermes 核心代码里直接 commit,无法独立版本化

2.1 注册表数据结构

providers/__init__.py 第 43-45 行:

源码视角 一 — 注册表与别名表
_REGISTRY: dict[str, ProviderProfile] = {}  # 规范名 → profile
_ALIASES: dict[str, str] = {}              # 别名 → 规范名
_discovered = False                         # 全局惰性发现锁

# Repo-root plugins/model-providers/ — populated at discovery time.
_BUNDLED_PLUGINS_DIR = (
    Path(__file__).resolve().parent.parent / "plugins" / "model-providers"
)

两个 dict + 一个布尔锁。_REGISTRY 存"规范名→profile";_ALIASES 存"别名→规范名",lookup 时先 alias 后 canonical。_discovered 保证发现只发生一次。

2.2 register_provider 与 last-writer-wins

providers/__init__.py 第 53-62 行:

源码视角 二 — 注册时同名覆盖
def register_provider(profile: ProviderProfile) -> None:
      # Register a provider profile by name and aliases.      Later registrations with the same name replace earlier ones — so user     plugins under ``$HERMES_HOME/plugins/model-providers/`` can override     bundled profiles without editing repo code.
    _REGISTRY[profile.name] = profile              # 同名直接覆盖
    for alias in profile.aliases:
        _ALIASES[alias] = profile.name             # 别名指向规范名

关键约束:

  • 同样的 name 后注册会覆盖先注册的 — user plugin 晚于 bundled 加载,所以 user 永远赢
  • aliases 不去重,新 aliases 全追加,旧 aliases 仍指向原 profile(除非被同名覆盖)
  • 用 dict 而非 list,让 get_provider_profile 是 O(1) lookup

2.3 三段式发现流程

providers/__init__.py 第 140-191 行:

源码视角 三 — _discover_providers
def _discover_providers() -> None:
      # Populate the registry by importing every provider plugin.      Order:       1. Bundled plugins at <repo>/plugins/model-providers/<name>/       2. User plugins at $HERMES_HOME/plugins/model-providers/<name>/       3. Legacy single-file modules at providers/<name>.py (back-compat)      Each step imports its plugins, which call ``register_provider()`` at     module-level. Later steps win on name collision.
    global _discovered
    if _discovered:
        return
    _discovered = True

    # 1. Bundled — shipped with hermes-agent.
    if _BUNDLED_PLUGINS_DIR.is_dir():
        for child in sorted(_BUNDLED_PLUGINS_DIR.iterdir()):
            if not child.is_dir() or child.name.startswith(("_", ".")):
                continue
            _import_plugin_dir(child, "bundled")

    # 2. User plugins — under $HERMES_HOME/plugins/model-providers/<name>/.
    #    These can override any bundled profile of the same name (last-writer-wins
    #    in register_provider()).
    user_dir = _user_plugins_dir()
    if user_dir is not None:
        for child in sorted(user_dir.iterdir()):
            if not child.is_dir() or child.name.startswith(("_", ".")):
                continue
            _import_plugin_dir(child, "user")

    # 3. Legacy single-file profiles at providers/<name>.py. Kept for
    #    back-compat — if someone drops a ``providers/foo.py`` into an
    #    editable install, it still works without the plugin layout.
    try:
        import pkgutil
        import providers as _pkg
        for _importer, modname, _ispkg in pkgutil.iter_modules(_pkg.__path__):
            if modname.startswith("_") or modname == "base":
                continue
            try:
                importlib.import_module(f"providers.{modname}")
            except ImportError as exc:
                logger.warning("Failed to import legacy provider module %s: %s", modname, exc)
    except Exception:
        pass

2.4 唯一性边界与模块命名

providers/__init__.py 第 102-137 行:

源码视角 四 — 同名插件在不同 HERMES_HOME profile 下的隔离
def _import_plugin_dir(plugin_dir: Path, source: str) -> None:
      # Import a single plugin directory so it self-registers.      ``source`` is "bundled" or "user", used only for log messages.
    init_file = plugin_dir / "__init__.py"
    if not init_file.exists():
        return

    # Give bundled plugins a stable import path
    # (``plugins.model_providers.<name>``) so relative imports within the
    # plugin work. User plugins load via ``importlib.util.spec_from_file_location``
    # with a unique module name so multiple HERMES_HOME profiles don't alias
    # each other.
    safe_name = plugin_dir.name.replace("-", "_")  # 注意:连字符在 module name 中非法
    if source == "bundled":
        module_name = f"plugins.model_providers.{safe_name}"
    else:
        module_name = f"_hermes_user_provider_{safe_name}"  # 用户插件隔离名

    if module_name in sys.modules:
        return  # already imported

    try:
        spec = importlib.util.spec_from_file_location(
            module_name, init_file, submodule_search_locations=[str(plugin_dir)]
        )
        if spec is None or spec.loader is None:
            return
        module = importlib.util_module_from_spec(spec)
        sys.modules[module_name] = module
        spec.loader.exec_module(module)
    except Exception as exc:
        logger.warning("Failed to load %s provider plugin %s: %s", source, plugin_dir.name, exc)
        sys.modules.pop(module_name, None)

两个细节:

  • replace("-", "_"):把 kimi-coding 这种带连字符的目录名映射成 kimi_coding,因为 Python module 名不能用 -
  • 用户插件使用 _hermes_user_provider_<name> 而不是 plugins.model_providers.<name>,避免多个 HERMES_HOME profile 同时激活时 sys.modules 互相覆盖

注意:发现是惰性的。只有第一次 get_provider_profile()list_providers() 调用才会扫描磁盘。直接 import providers/__init__.py 不会触发发现;如果代码路径要读 plugin 状态但没经过这两个入口,需要显式调用 _discover_providers()

本节小结

  • 发现顺序:bundled → user → legacy,后注册覆盖先注册(user 永远赢)
  • _REGISTRY / _ALIASES 两个 dict + _discovered 单次锁,让 lookup O(1) 且幂等
  • 用户插件模块名带 _hermes_user_provider_ 前缀,避免多 profile 下 sys.modules 串扰
  • 惰性发现:直接 import providers/__init__.py 不会触发扫描

三、api_mode 与 base_url 的运行时推导

What — api_mode 是什么?

api_mode 决定 Hermes 用哪种 wire 协议调用模型。当前支持四种:

  • chat_completions(OpenAI 兼容,绝大多数 provider)
  • anthropic_messages(Anthropic Messages API)
  • codex_responses(OpenAI Codex Responses API)
  • bedrock_converse(AWS Bedrock Converse API)

每种模式用不同的 client、不同的请求构造、不同的流式解码路径。选错 api_mode = 立刻 404 或 401

Why — 为什么要在运行时推导而不是静态声明?

用户配置里只写"用哪个 provider、用哪个 model"是不够的——同一个 custom provider 指向不同 endpoint 时,api_mode 也不同(Kimi 的 /coding 端点用 anthropic_messages,但默认 vLLM 端点用 chat_completions)。从 provider+base_url 推导 api_mode 才能正确路由。

没有运行时推导会发生什么?

  • 用户在 config.yaml 里手填 api_mode: anthropic_messages,错配概率陡升
  • 同一 provider 的多个 endpoint 无法差异化处理
  • 新增 provider 必须在两处同时更新(profile + config schema),容易遗漏

3.1 determine_api_mode 的三层优先级

hermes_cli/providers.py 第 533-572 行:

源码视角 一 — determine_api_mode 三层判定
def determine_api_mode(provider: str, base_url: str = "") -> str:
      # Determine the API mode (wire protocol) for a provider/endpoint.      Resolution order:       1. Known provider → transport → TRANSPORT_TO_API_MODE.       2. URL heuristics for unknown / custom providers.       3. Default: 'chat_completions'.
    pdef = get_provider(provider)
    if pdef is not None:
        # Even for known providers, check URL heuristics for special endpoints
        # (e.g. kimi /coding endpoint needs anthropic_messages even on 'custom')
        if base_url:
            url_lower = base_url.rstrip("/").lower()
            if "api.kimi.com/coding" in url_lower:
                return "anthropic_messages"        # 同一 provider 不同 endpoint
            if url_lower.endswith("/anthropic") or "api.anthropic.com" in url_lower:
                return "anthropic_messages"
            if "api.openai.com" in url_lower:
                return "codex_responses"
        return TRANSPORT_TO_API_MODE.get(pdef.transport, "chat_completions")

    # Direct provider checks for providers not in HERMES_OVERLAYS
    if provider == "bedrock":
        return "bedrock_converse"

    # URL-based heuristics for custom / unknown providers
    if base_url:
        url_lower = base_url.rstrip("/").lower()
        hostname = base_url_hostname(base_url)
        if url_lower.endswith("/anthropic") or hostname == "api.anthropic.com":
            return "anthropic_messages"
        if hostname == "api.kimi.com" and "/coding" in url_lower:
            return "anthropic_messages"
        if hostname == "api.openai.com":
            return "codex_responses"
        if hostname.startswith("bedrock-runtime.") and base_url_host_matches(base_url, "amazonaws.com"):
            return "bedrock_converse"

    return "chat_completions"

三层优先级:

  1. profile.transport:从 ProviderProfile 的 transport 字段映射到 api_modeTRANSPORT_TO_API_MODE 表)
  2. URL 启发式:同一 provider 但 endpoint 路径不同(如 /coding),覆盖 profile 默认
  3. 纯 URL 启发式:未注册的 provider 用 hostname 推断(api.anthropic.com → anthropic_messages)

3.2 Custom Provider 的双形态

Custom provider 是 Hermes 的"瑞士军刀"——它可以指向本地 Ollama、vLLM、llama.cpp、也可以指向 GLM-5.2 on Volcengine ARK 或其他 OpenAI-compatible reasoning 端点。看 plugins/model-providers/custom/__init__.py 第 22-60 行:

源码视角 二 — Custom reasoning 三态
class CustomProfile(ProviderProfile):
      # Custom/Ollama local provider — think=false and num_ctx support.

    def build_api_kwargs_extras(
        self,
        *,
        reasoning_config: dict | None = None,
        ollama_num_ctx: int | None = None,
        **ctx: Any,
    ) -> tuple[dict[str, Any], dict[str, Any]]:
        extra_body: dict[str, Any] = {}
        top_level: dict[str, Any] = {}

        # Ollama context window
        if ollama_num_ctx:
            options = extra_body.get("options", {})
            options["num_ctx"] = ollama_num_ctx
            extra_body["options"] = options

        # Reasoning 三态:
        #   - disabled  → extra_body.think = False (Ollama 的 thinking-off 标志)
        #   - enabled + effort 设 → top-level reasoning_effort 字符串(GLM/ARK 的格式)
        #   - enabled + no effort  → 都省略(让端点用 server-side default)
        #
        # 注意:不要 emit think=True — 它是 Ollama-only flag,GLM/vLLM
        # 不识别会 400。Mirror DeepSeek/Zai 的 precedent。
        if reasoning_config and isinstance(reasoning_config, dict):
            _effort = (reasoning_config.get("effort") or "").strip().lower()
            _enabled = reasoning_config.get("enabled", True)
            if _effort == "none" or _enabled is False:
                extra_body["think"] = False
            elif _effort:
                top_level["reasoning_effort"] = _effort
        return extra_body, top_level

Custom 的核心 quirk:

  • 不强制 think=True:因为 Ollama-thinking-on 是 server default,强制会破坏其他 OpenAI-compatible 端点
  • default_max_tokens=65536:Ollama 默认 num_predict=128,如果不设就会截断
  • ollama_num_ctx:把用户配置映射到 extra_body.options.num_ctx,即 Ollama 上下文窗口

3.3 resolve_provider_full 的解析链

当用户在 --provider 标记里写一个名字时,Hermes 走完整的解析链。看 hermes_cli/providers.py 第 700-769 行:

源码视角 三 — resolve_provider_full 解析链
def resolve_provider_full(
    name: str,
    user_providers: Optional[Dict[str, Any]] = None,
    custom_providers: Optional[List[Dict[str, Any]]] = None,
) -> Optional[ProviderDef]:
      # Full resolution chain: built-in → models.dev → user config.
    canonical = normalize_provider(name)
    raw = name.strip().lower()

    # 0. User-defined config providers win over the built-in alias table.
    #    A user who declares ``providers.<name>`` in config.yaml has stated
    #    explicit intent for that name — it must not be hijacked by a
    #    legacy vendor alias (e.g. bare "openai" → "openrouter"). Resolve
    #    the raw name against user config FIRST so a configured ``providers.openai``
    #    stays as the user's custom openai endpoint instead of being silently
    #    routed through OpenRouter by the alias table.
    ...
    # 1. Custom providers list (custom_providers: in config.yaml)
    pdef = resolve_custom_provider(name, custom_providers)
    if pdef is not None:
        return pdef

    # 2. User providers dict (providers: in config.yaml)
    pdef = resolve_user_provider(name, user_providers)
    if pdef is not None:
        return pdef

    # 3. Built-in providers (HERMES_OVERLAYS / models.dev)
    return get_provider(name)

顺序关键:user 配置 > custom 列表 > built-in alias 表。

第 0 步用 raw name 而不是 canonical,是因为有些 legacy alias 会把用户的 providers.openai 抢走(裸 "openai" 在 alias 表里默认指向 openrouter)。先查 user config 是为了防止 hijack。

实战提示

用户在 config.yaml 写 providers: { openai: { base_url: ..., key_env: OPENAI_KEY } },会比 built-in 的 "openai" → "openrouter" alias 优先生效。如果不写这个 dict,又跑 --provider openai,会得到 OpenRouter 路由——这是 by-design 的回退,不是 bug。

本节小结

  • api_mode 由三层优先级决定:profile.transport → URL 启发式(同 provider 不同 endpoint) → 纯 hostname 启发式
  • Custom profile 通过 build_api_kwargs_extras 同时支持 Ollama think 和 GLM reasoning_effort,不强制 emit 不兼容字段
  • 解析链顺序 user config → custom list → built-in alias,raw name 优先查询防止 alias hijack

四、凭证池:多 Key 轮换与失败回退

What — CredentialPool 是什么?

CredentialPool 把同一个 provider 的多个凭证(API key、OAuth token、custom endpoint)持久化到 ~/.hermes/auth.json,并按 priority + 状态机 排序与轮换。当主 key 遇到 401/429/timeout 时,agent 自动 fallback 到下一个 key,无需用户干预。

Why — 为什么需要凭证池?

单 key 用户也会遇到以下场景:

  • OpenRouter 速率限制(同一 IP 短时间太多请求)
  • Anthropic OAuth token 过期,需要切换到 API key
  • Copilot 月度配额耗尽,回退到 OpenAI 直连
  • 本地 Ollama 突然离线,自动切换到云端备份

没有凭证池:用户必须手动改 config.yaml、重启 agent、再次确认有效。

没有 CredentialPool 会发生什么?

  • 任何一个 401/429/5xx 都会让长任务中途死亡
  • 用户需要持续监控 + 手动切换,违反"agent 自治"理念
  • OpenAI Codex 15 分钟 token 过期时,agent 会带着过期 token 跑 5 分钟才报错

4.1 CredentialPool 数据结构

agent/credential_pool.py 第 508-512 行:

源码视角 一 — CredentialPool 初始化
class CredentialPool:
    def __init__(self, provider: str, entries: List[PooledCredential]):
        self.provider = provider
        self._entries = sorted(entries, key=lambda entry: entry.priority)  # priority 升序
        self._current_id: Optional[str] = None
        self._strategy = get_pool_strategy(provider)                       # provider 级别策略

几个关键点:

  • priority 排序priority 越小越优先;用户用 manual:<id> 添加的条目默认 0
  • strategy:provider 级别策略(round_robin / priority_only / fail_then_next),决定同一 priority 时的轮换顺序
  • 线程安全_current_id 读写受 _auth_store_lock 保护,多线程 agent 共用一份池

4.2 load_pool 的种子逻辑

每次 load_pool 调用都会从 .env、singleton 文件、custom providers 三类来源重新播种。看 agent/credential_pool.py 第 2158-2185 行:

源码视角 二 — load_pool 三来源播种
def load_pool(provider: str) -> CredentialPool:
    provider = (provider or "").strip().lower()
    raw_entries = read_credential_pool(provider)
    raw_needs_sanitization = any(
        isinstance(payload, dict)
        and sanitize_borrowed_credential_payload(payload, provider) != payload
        for payload in raw_entries
    )
    entries = [PooledCredential.from_dict(provider, payload) for payload in raw_entries]

    if provider.startswith(CUSTOM_POOL_PREFIX):
        # Custom endpoint pool — seed from custom_providers config and model config
        custom_changed, custom_sources = _seed_custom_pool(provider, entries)
        changed = raw_needs_sanitization or custom_changed
        changed |= _prune_stale_seeded_entries(entries, custom_sources)
    else:
        singleton_changed, singleton_sources = _seed_from_singletons(provider, entries)
        env_changed, env_sources = _seed_from_env(provider, entries)
        changed = raw_needs_sanitization or singleton_changed or env_changed
        changed |= _prune_stale_seeded_entries(entries, singleton_sources | env_sources)
        changed |= _normalize_pool_priorities(provider, entries)

    if changed:
        write_credential_pool(
            provider,
            [entry.to_dict() for entry in sorted(entries, key=lambda item: item.priority)],
        )
    return CredentialPool(provider, entries)
    entries = [PooledCredential.from_dict(provider, payload) for payload in raw_entries]

    if provider.startswith(CUSTOM_POOL_PREFIX):
        # Custom endpoint pool — seed from custom_providers config and model config
        custom_changed, custom_sources = _seed_custom_pool(provider, entries)
        changed = raw_needs_sanitization or custom_changed
        changed |= _prune_stale_seeded_entries(entries, custom_sources)
    else:
        singleton_changed, singleton_sources = _seed_from_singletons(provider, entries)
        env_changed, env_sources = _seed_from_env(provider, entries)
        changed = raw_needs_sanitization or singleton_changed or env_changed
        # ``load_pool()`` is a non-destructive read for env-seeded entries: a
        # process missing a provider env var must not delete the persisted
        # pool entry for every other process (#9331). File-backed singletons
        # still prune when their backing file is gone.
        changed |= _prune_stale_seeded_entries(
            entries,
            singleton_sources | env_sources,
            prune_env_sources=False,
        )
        changed |= _normalize_pool_priorities(provider, entries)

    if changed:
        new_ids = {entry.id for entry in entries}
        write_credential_pool(
            provider,
            [entry.to_dict() for entry in sorted(entries, key=lambda item: item.priority)],
            removed_ids=disk_ids - new_ids,
        )
    return CredentialPool(provider, entries)

三来源:

  • persisted pool~/.hermes/auth.jsonpools.<provider> 段,用户 hermes cred add 添加
  • singletons~/.hermes/<provider>-auth.json 等 per-provider 文件
  • envprocess.env[provider.env_vars[0]]

关键防御:prune_env_sources=False 防止"当前进程没有这个 env var 就把池里的 env-seeded 条目删掉"——多 profile/多终端共享同一份 auth.json 时这是常见坑(issue #9331)。

4.3 三种状态机

agent/credential_pool.py 第 56-77 行:

源码视角 三 — 凭证状态机
STATUS_OK = "ok"
STATUS_EXHAUSTED = "exhausted"
# Terminal failure — the credential will never recover on its own.  Used for
# upstream-permanent OAuth states like ``token_invalidated`` / ``token_revoked``
# where retrying after a TTL cooldown is guaranteed to fail.  ``DEAD`` entries
# are excluded from rotation unconditionally and only clear when an explicit
# write-side sync (e.g. ``_save_codex_tokens`` after a fresh device-code
# login) rewrites the tokens.
STATUS_DEAD = "dead"

# OAuth error reasons that indicate the credential is permanently invalid
# server-side and cannot be recovered by retry/refresh.
_TERMINAL_AUTH_REASONS = frozenset({
    "token_invalidated",   # OpenAI Codex: "Your authentication token has been invalidated."
    "token_revoked",        # OAuth 2.0 RFC 7009: token explicitly revoked
    "invalid_token",        # RFC 6750: bearer token is malformed/expired/revoked
    "invalid_grant",        # RFC 6749: refresh_token rejected during refresh
    "unauthorized_client",  # RFC 6749: client no longer authorized
    "refresh_token_reused", # Single-use refresh token consumed by another process
})

三态:

状态触发轮换中恢复路径
ok 默认
exhausted 429 / rate limit 是(带 TTL 冷却) TTL 到期自动恢复 ok
dead 命中 _TERMINAL_AUTH_REASONS 需要重新走 OAuth 设备码 / device login 流程

4.4 OAuth Provider 的特殊处理

OpenAI Codex 走 OAuth device-code 流程,token 15 分钟过期。Hermes 不直接保存过期 token,而是把 token provider 设计成 per-request callable。看 agent/agent_runtime_helpers.py 第 1875-1898 行:

源码视角 四 — MiniMax OAuth 的 per-request token provider
# MiniMax OAuth: swap static string for a per-request callable token
# provider so the rebuilt client survives 15-min token expiry. See
# the matching block in agent_init.py for the full rationale.
if new_provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key:
    try:
        from hermes_cli.auth import build_minimax_oauth_token_provider
        effective_key = build_minimax_oauth_token_provider()
    except Exception as _mm_exc:  # noqa: BLE001
        import logging as _logging
        _logging.getLogger(__name__).warning(
            "MiniMax OAuth: failed to install per-request token provider "
            "on switch (%s); using static bearer.",
            _mm_exc,
        )

agent.api_key = effective_key
agent._anthropic_api_key = effective_key
agent._anthropic_base_url = base_url or getattr(agent, "_anthropic_base_url", None)
agent._anthropic_client = build_anthropic_client(
    effective_key, agent._anthropic_base_url,
    timeout=get_provider_request_timeout(agent.provider, agent.model),
)

关键设计:

  • callable token:每次 SDK 发请求时调一次 callable 拿最新 token,而不是构造 client 时就把 token 字符串固定下来
  • client 重建不重置 tokenswitch_model 重建 client 时 token provider 还在原位,token=callable 模式天然支持 expiry 续期
  • 异常降级:如果 callable 构建失败,回落到 static bearer,至少能跑(虽然 15 分钟后会 401)

注意:manual:* 来源 vs 自动来源

用户用 hermes cred add 添加的条目带 manual: 前缀,priority=0,永远排在自动来源(singleton / env)前面。这样用户在企业 incident 期间手动加的"备用 key"不会被自动来源挤掉。

本节小结

  • CredentialPool 通过 priority + strategy 实现多 key 轮换,按 provider 隔离
  • 三来源播种(persisted / singletons / env),env 缺失不破坏多进程共享
  • 三态状态机 ok / exhausted / deaddead 不参与轮换直到 OAuth 重新授权
  • OAuth provider 用 callable token 解决 15 分钟过期问题

五、运行时切换:switch_model 与客户端重建

What — switch_model 是什么?

switch_model 是在 agent 已经构造好之后,原地替换 当前模型/provider 的运行时入口。被 /model 命令、gateway 的 /model 处理器、TUI 的模型选择器共同调用,需要在不重启 agent 的前提下重建 OpenAI/Anthropic 客户端、刷新凭证池、重置 transport cache。

Why — 为什么需要原地切换而不是重建 agent?

重建 agent 会丢失:

  • 已加载的技能(skills)列表
  • 会话历史(messages 列表)
  • 插件预取缓存(memory / honcho prefetch)
  • 已注册的钩子状态(plugin hooks)

原地切换必须精确地把"会被换掉的部分"换掉、"不会被换掉的部分"保留。看 agent/agent_runtime_helpers.py 第 1727-1798 行就能感受到这种精细。

没有 switch_model 会发生什么?

  • 用户在 /model openai-codex 之后必须 /new 重开会话——历史全丢
  • prompt cache 全断,next turn 计费按 full prompt
  • 技能加载、记忆预取都要重新触发,延迟飙升

5.1 switch_model 的快照+回滚模式

agent/agent_runtime_helpers.py 第 1751-1798 行:

源码视角 一 — 字段快照防御
# Defense-in-depth: ensure OpenCode base_url doesn't carry a trailing
# /v1 into the anthropic_messages client, which would cause the SDK to
# hit /v1/v1/messages.  `model_switch.switch_model()` already strips
# this, but we guard here so any direct callers (future code paths,
# tests) can't reintroduce the double-/v1 404 bug.
if (
    api_mode == "anthropic_messages"
    and new_provider in {"opencode-zen", "opencode-go"}
    and isinstance(base_url, str)
    and base_url
):
    base_url = re.sub(r"/v1/?$", "", base_url)

old_model = agent.model
old_provider = agent.provider

# ── Snapshot all fields the swap+rebuild can mutate ──
# If the rebuild raises (bad API key, network error, build_anthropic_client
# failure, etc.) we restore these atomically so the agent isn't left with a
# new model/provider name paired with the OLD client — that mismatch causes
# HTTP 400s like "claude-sonnet-4-6 is not supported on openai-codex" on the
# next turn.
_MISSING = object()
_snapshot = {
    name: getattr(agent, name, _MISSING)
    for name in (
        "model",
        "provider",
        "base_url",
        "api_mode",
        "api_key",
        "client",
        "_anthropic_client",
        "_anthropic_api_key",
        "_anthropic_base_url",
        "_is_anthropic_oauth",
        "_config_context_length",
    )
}

防御设计的两个关键点:

  • 10 个字段快照:任何客户端重建失败都能原子回滚,不会出现"模型名换了但 client 还指向旧 endpoint"的脏状态
  • _MISSING sentinel:区分"属性原本就不存在"和"属性存在但值为 None",让 bare-constructed agent(测试用)也能正确回滚

5.2 api_mode 推导防御

agent/agent_runtime_helpers.py 第 1741-1758 行:

源码视角 二 — api_mode 兜底与 URL 防御
from hermes_cli.providers import determine_api_mode

# ── Determine api_mode if not provided ──
if not api_mode:
    api_mode = determine_api_mode(new_provider, base_url)

# Defense-in-depth: ensure OpenCode base_url doesn't carry a trailing
# /v1 into the anthropic_messages client, which would cause the SDK to
# hit /v1/v1/messages.  `model_switch.switch_model()` already strips
# this, but we guard here so any direct callers (future code paths,
# tests) can't reintroduce the double-/v1 404 bug.
if (
    api_mode == "anthropic_messages"
    and new_provider in {"opencode-zen", "opencode-go"}
    and isinstance(base_url, str)
    and base_url
):
    base_url = re.sub(r"/v1/?$", "", base_url)

防御点:

  • api_mode 兜底:调用方没传就用 determine_api_mode 推导,但不做 transport 改动;如果上游 model_switch.switch_model() 推过了,这里再次校验
  • /v1 截尾:Anthropic Python SDK 在 base_url 末尾带 /v1 时会拼出 /v1/v1/messages,故统一 strip

5.3 凭证池的"换 provider 就重载"

agent/agent_runtime_helpers.py 第 1822-1841 行:

源码视角 三 — credential pool 重载
# ── Reload credential pool for the new provider (issue #52727) ──
# Without this, ``recover_with_credential_pool`` sees a
# ``pool.provider != agent.provider`` mismatch and short-circuits,
# leaving the new provider with no rotation/recovery on 401/429 and
# burning the original pool's entries. Only reload when the provider
# actually changed (or the pool was missing) — re-selecting the same
# provider must not churn the pool reference. A reload failure is
# logged + swallowed: the switch itself must still complete.
old_norm = (old_provider or "").strip().lower()
new_norm = (new_provider or "").strip().lower()
if old_norm != new_norm or getattr(agent, "_credential_pool", None) is None:
    try:
        from agent.credential_pool import load_pool
        agent._credential_pool = load_pool(new_provider)
    except Exception as _pool_exc:  # noqa: BLE001
        logger.warning(
            "switch_model: credential pool reload failed for %s (%s); "
            "continuing without pool rotation this turn",
            new_provider, _pool_exc,
        )

关键约束:

  • 只在 provider 真改变时重载:同一 provider 重新选 model 不能 churn pool(issue #52727)
  • 失败不中断:pool 加载失败只 warn,因为 switch 必须继续;pool 缺失时还能跑(只是无轮换)
  • 短重载load_poolauth.json 后立即返回,开销可忽略

5.4 MoA 虚拟 Provider 的特殊路径

agent/agent_runtime_helpers.py 第 1843-1862 行:

源码视角 四 — MoA pin chat_completions
# ── Build new client ──
if (new_provider or "").strip().lower() == "moa":
    from agent.moa_loop import MoAClient

    # The MoA virtual provider speaks only chat.completions via the
    # MoAClient facade — the aggregator's real transport
    # (codex_responses / anthropic_messages) is resolved and applied
    # *inside* the reference/aggregator fan-out, never on the outer
    # primary call. determine_api_mode("moa", ...) above may have left
    # api_mode set to the aggregator's transport; if the conversation
    # loop sees that, it dispatches client.responses.create (which the
    # facade has no .responses for) and the call falls through to the
    # moa://local placeholder → HTTP 404 → fallback to a reference
    # model. Pin chat_completions here so the primary call always goes
    # through MoAClient.chat.completions, matching agent_init.py.
    agent.api_mode = "chat_completions"
    agent.api_key = api_key or "moa-virtual-provider"
    agent.base_url = "moa://local"
    agent._client_kwargs = {}
    agent.client = MoAClient(agent.model or "default")

MoA 是"聚合器之上的聚合器":外层是 chat_completions 协议,但内层是多个 reference model + 一个 aggregator 的 fan-out。switch_model 必须把外层 api_mode 强制钉成 chat_completions,否则外层 client 会试图调 client.responses.create(MoAClient 没有这个方法)→ 404 → fallback 跑偏。

实战提示

用户在 gateway 里跑 /model openai-codex 切换时,gateway 会先调 model_switch.switch_model() 校验 model 是否可用、找到对应的 base_url / api_key,再调 agent_runtime_helpers.py::switch_model 重建 client。如果用户在子代理里调用 /model,会被路由回主 agent 的 runtime(gateway 不直接操作子 agent 的 client)。

本节小结

  • switch_model 用 10 字段快照防御回滚,避免半切换脏状态
  • api_mode 没传时由 determine_api_mode 兜底,并防御 OpenCode /v1 双前缀
  • 凭证池仅在 provider 真改变时重载(issue #52727)
  • MoA 虚拟 provider 强制外层 chat_completions,避免 client.responses.create 缺失

六、FAQ 20 问

FAQ 分组说明

本节围绕 ProviderProfile / 插件发现 / api_mode 推导 / 凭证池 / 运行时切换 5 大主题,按"基础概念 / 进阶机制 / 故障排查"递进排列。

Q1. ProviderProfileProviderDef 是一回事吗?

不是。providers/base.pyProviderProfile 是声明式 dataclass(focus 在"如何调"),hermes_cli/providers.pyProviderDef 是查找结果(focus 在"调谁")。一个 provider plugin 提交 Profile,get_provider() 把它转成 ProviderDef 给 transport 用。

Q2. 为什么用 dataclass 而不是 Pydantic BaseModel?

零依赖 + 性能。providers/base.py 第 38 行用 @dataclass:plugin 加载时不引入 pydantic 依赖,热路径(每次 build_extra_body 调用)零开销。Profile 字段全是字面常量,没有动态校验需求。

Q3. 同一个 provider 在 model:fallback_model: 都指定会怎样?

fallback_model 不会自动生效。需要在 AIAgent 构造时显式传 fallback_model=,或者用 fallback_providers: 列表(hermes_cli/config.py 第 909 行)。config.yaml 里 model: 下的 fallback_model: 子键是 setup 期间的 UI 占位,运行时由 _try_activate_fallback 处理。

Q4. 第三方怎么加一个全新 provider?

~/.hermes/plugins/model-providers/<name>/ 放一个 __init__.py,调用 register_provider(profile) 即可。无需修改任何核心代码,_discover_providers 会在第一次 lookup 时自动 import。

Q5. bundled 和 user 同名,谁赢?

user 赢。providers/__init__.py 第 140-172 行的发现顺序是 bundled → user → legacyregister_provider 用 dict 覆盖语义所以后者赢。第三方可以无侵入替换任何 built-in profile。

Q6. 怎么让 /model 切换不丢历史?

switch_model 而不是 /newswitch_model 重建 client 但保留 messages 列表、skills 缓存、plugin 钩子状态;/new 则会开启新会话。

Q7. 凭证池在多 profile 下会冲突吗?

不会。agent/credential_pool.py 第 2342 行的 load_pool 路径基于 get_hermes_home(),每个 profile 自己的 auth.json 独立。user plugin 模块名带 _hermes_user_provider_ 前缀避免 sys.modules 串扰。

Q8. OMIT_TEMPERATURE sentinel 怎么用?

fixed_temperature = OMIT_TEMPERATUREproviders/base.py 第 89 行:fixed_temperature: Any = None,赋值时如果想完全不发送这个字段就传 sentinel。transport 在合并参数时检查 is fixed_temperature == OMIT_TEMPERATURE,避免把 None 误传为 "null"。

Q9. Native Anthropic 和 Anthropic-on-OpenRouter 走同一个 client 吗?

不走。agent/agent_runtime_helpers.py 第 1863-1898 行:api_mode == "anthropic_messages" 时走 build_anthropic_client(Anthropic SDK + x-api-key);chat_completions 时走 OpenAI(...)。两个 client 是分开的字段 agent._anthropic_client vs agent.client

Q10. fetch_models 失败会怎样?

回退到 fallback_modelsproviders/base.py 第 162-217 行:默认实现 return None,调用方(hermes_cli/models.py)检查 None 后切换到静态 fallback_models 列表。不要让 fetch_models 抛异常——它被设计为 best-effort。

Q11. recover_with_credential_pool 触发条件是什么?

401/429/5xx。run_agent.py_recover_with_credential_pool:当 API 返回 401(凭证失效)、429(rate limit)、或 5xx(上游错误)时,agent 取出 pool 中下一条 entry 重建 client 重试。如果所有 entry 都 exhausted/就放弃。

Q12. OAuth 设备码流程怎么工作?

三段:device_code → user 访问 verify URL → poll token。Hermes 把 device_code、access_token、refresh_token 都存在 ~/.hermes/auth.jsontokens.<provider> 段。15 分钟前主动 refresh(CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS)。

Q13. 我可以同时用 OpenRouter 和 native OpenAI 吗?

可以,但需要两个 provider profile。config.yaml 写 model: { provider: openai-codex, model: gpt-5.5 }fallback_providers: [{ provider: openrouter, model: anthropic/claude-opus-4.8 }]。主 provider 用 Codex,fallback 走 OpenRouter,两套凭证、两个 client 互不干扰。

Q14. api_key 从 env 读还是从 .env 读?

两套机制,env 优先。hermes_cli/config.pyload_env()~/.hermes/.env 加载到 os.environ(不覆盖已存在的);代码读 os.environ.get(OPENAI_API_KEY),所以 shell env 优先于 .env

Q15. _auth_store_lock 是什么?

进程内全局 threading.Lock。hermes_cli/auth.py_auth_store_lock 保护 auth.json 的并发读写。多线程 agent 共用同一份池时,load_poolwrite_credential_pool 不会撕裂文件。

Q16. fallback_models 什么时候被使用?

live fetch 失败时。hermes_cli/models.pyfetch_models 返回 None(网络错误、auth 失败)时,UI 回退到 fallback_models tuple。这是"硬编码"的安全网,不应依赖它代替 fetch_models

Q17. 我能让 /model 切换不重置 prompt cache 吗?

不能保证。Provider 变化一定会破坏 cache(不同 endpoint 的 cache key 域不同);同 provider 内 model 变化可能保留(如 Anthropic 跨 Sonnet 4 变体),但 Hermes 不主动承诺这一点。详见 AGENTS.md "Prompt Caching Must Not Break" 节。

Q18. determine_api_modeapi_mode 字段哪个优先?

api_mode 字段优先。AIAgent.__init__api_mode 是显式参数;如果没传或为空,determine_api_mode 才被调用。这意味着用户可以在 config.yamlmodel: 段显式覆盖推导结果。

Q19. provider 插件能不能改 AIAgent 核心字段?

不能直接改,必须通过 hook 暴露。AGENTS.md "Plugins" 节明确:plugins 不能改 core 文件。如果 plugin 需要新能力,必须扩 ctx 表面(新增 hook、新 ctx method),让 generic plugin 表面变宽,而不是硬编码 plugin 特定逻辑到 core。

Q20. 怎么 debug provider 解析问题?

三步排查:

  • hermes provider list 列出当前所有已注册 profile
  • HERMES_LOG_LEVEL=DEBUG 跑一次 chat,看 _discover_providers / determine_api_mode / load_pool 三个调用点的输出
  • ~/.hermes/logs/agent.log 末尾的 provider=X base_url=Y api_mode=Z

本节总纲

  • Provider 解析是"声明式 profile → transport 推导 → 凭证注入 → 客户端构造 → 运行时切换"的五段流水线
  • 每段都设计成可独立扩展:profile 字段可 subclass、URL 启发式可加、credential pool 策略可换、客户端构造按 api_mode 分发
  • 调试时先看 /provider list,再看 agent.log 的 transport 选择行,最后才深入源码

后续 Roadmap

下一篇 第 4 篇:会话存储层:SQLite + FTS5 + 血缘追踪 将深入 hermes_state.pySessionDB:消息如何入库、FTS5 全文索引如何加速历史检索、parent_session_id 如何构建子代理与 fork 会话的血缘图、以及 WAL checkpoint 与跨 profile 隔离策略。


posted @ 2026-07-16 00:47  左扬  阅读(54)  评论(0)    收藏  举报