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_mode 在 ProviderProfile + 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 组:
看 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_headers、fixed_temperature | client 构造时一次性生效 | GitHub Copilot 编辑器 UA、Anthropic x-api-key 头 |
| 请求级 | build_extra_body、build_api_kwargs_extras、prepare_messages | 每次 chat.completions.create 调用时合并 | OpenRouter reasoning、Qwen vl_high_resolution_images、Custom 的 think |
| 模型目录 | fetch_models、fallback_models | model picker / fallback 决策 | OpenRouter 公开目录、Anthropic x-api-key 鉴权目录 |
| 视觉能力 | supports_vision、supports_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 暴露出来。
看 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 行:
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 行:
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 行:
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 行:
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"
三层优先级:
- profile.transport:从 ProviderProfile 的 transport 字段映射到 api_mode(TRANSPORT_TO_API_MODE 表)
- URL 启发式:同一 provider 但 endpoint 路径不同(如 /coding),覆盖 profile 默认
- 纯 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 行:
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 行:
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 行:
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 行:
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.json 里 pools.<provider> 段,用户 hermes cred add 添加
- singletons:~/.hermes/<provider>-auth.json 等 per-provider 文件
- env:process.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: 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 重建不重置 token:switch_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 / dead,dead 不参与轮换直到 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 行:
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 行:
# ── 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_pool 读 auth.json 后立即返回,开销可忽略
5.4 MoA 虚拟 Provider 的特殊路径
看 agent/agent_runtime_helpers.py 第 1843-1862 行:
# ── 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. ProviderProfile 和 ProviderDef 是一回事吗?
不是。providers/base.py 的 ProviderProfile 是声明式 dataclass(focus 在"如何调"),hermes_cli/providers.py 的 ProviderDef 是查找结果(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 → legacy,register_provider 用 dict 覆盖语义所以后者赢。第三方可以无侵入替换任何 built-in profile。
Q6. 怎么让 /model 切换不丢历史?
用 switch_model 而不是 /new。switch_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_TEMPERATURE。看 providers/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_models。看 providers/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.json 的 tokens.<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.py 的 load_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_pool 与 write_credential_pool 不会撕裂文件。
Q16. fallback_models 什么时候被使用?
live fetch 失败时。看 hermes_cli/models.py:fetch_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_mode 与 api_mode 字段哪个优先?
api_mode 字段优先。在 AIAgent.__init__ 里 api_mode 是显式参数;如果没传或为空,determine_api_mode 才被调用。这意味着用户可以在 config.yaml 的 model: 段显式覆盖推导结果。
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.py 的 SessionDB:消息如何入库、FTS5 全文索引如何加速历史检索、parent_session_id 如何构建子代理与 fork 会话的血缘图、以及 WAL checkpoint 与跨 profile 隔离策略。

浙公网安备 33010602011771号