Hermes Agent 接入第三方模型网关踩坑实录:401、120 秒超时与 DNS 劫持

前言

前阵子给自己的 Hermes Agent 接了一个第三方模型中转站(agentrouter.org,提供 gpt-6.1-sol 这类走 Codex 渠道的模型)。配好 key、选好模型,结果一调用就是满屏报错:先是 APIConnectionError 重试三次,接着变成 Codex stream produced no parsed stream event within 120s,最后自动降级到别的 provider 上去。

换成 curl 手动打接口,又变成 {"message":"unauthorized client detected"}。一度以为 key 被风控封了。折腾了一晚上才定位到——这是三个独立的坑叠在一起:本地 DNS 被解析到了错误地址、网关按客户端 UA 做白名单、模型只支持 Responses API 不支持 chat/completions。

这篇就把完整排查过程和最终配置整理出来。如果你也在给 Hermes、Codex CLI、Cherry Studio 之类的客户端接第三方中转站,这篇大概率能帮你省掉一晚上。

故障现象:三种报错,三种完全不同的原因

先把症状拆开,因为这三条报错很容易被误读成同一个问题(“网络不好”或者“key 被封了”):

  • APIConnectionError: Connection error. —— TCP 层就没连上,压根没到 HTTP 请求这一步。
  • Codex stream produced no parsed stream event within 120s —— 连接建立了、TLS 也握手了,但服务端一个 SSE 事件都没吐,Hermes 的 TTFB 保护机制直接掐断连接。
  • unauthorized client detected, contact support —— 服务端返回的 HTTP 401,但报错文案和“key 无效”一模一样,极具误导性。

关键判断:如果 curl 连 https://域名/ 首页都超时,那你先去查 DNS 和网络,别怀疑 key;如果首页能开、API 报 401,那就去查 UA / 认证头 / 接口路径。顺序搞反会浪费大量时间。

第一步:用 DoH 验证 DNS 解析是不是被投毒了

这是本次最隐蔽的一个坑。内网 DNS(172.16.12.10)把 agentrouter.org 解析成了 69.50.221.20,还返回了一个 2a03:2880:f10d:183:face:b00c:: 开头的 IPv6(这段是 Facebook 的网段,明显不对)。那个 IP 是死的,所以每次请求都卡在 connect 阶段。

验证方法很简单,绕开本地 DNS 用 DoH 查一次真实解析:

# 本地解析(可能是错的)
getent hosts agentrouter.org

# DoH 真实解析
curl -sS "https://dns.google/resolve?name=agentrouter.org&type=A"

返回的 Answer 里如果是阿里云 ALB 的地址(形如 8.214.x.x),而本地拿到的是别的 IP,那就实锤了。这时候直接指定 IP 试一下,能通就说明是 DNS 的问题:

curl -sS --resolve agentrouter.org:443:8.214.160.125 \
  -o /dev/null -w "code=%{http_code} conn=%{time_connect} tls=%{time_appconnect}\n" \
  --max-time 20 https://agentrouter.org/v1/models

拿到 code=401 其实是好消息——说明网络通了,401 是下一层的问题。

修复:把真实 IP 钉进 /etc/hosts,但别写死

直接改 /etc/hosts 能立刻恢复,但阿里云 ALB 的 IP 是会轮换的,写死一个地址过几个月又挂了。所以我写了个小脚本:每天用 DoH 重新查一次,自动替换 /etc/hosts 里的托管区块,幂等可重复执行。

#!/usr/bin/env bash
# /root/.hermes/scripts/pin-agentrouter-dns.sh
set -euo pipefail

DOMAIN=agentrouter.org
MARK="# ${DOMAIN}: LAN DNS hijack guard (managed by pin-agentrouter-dns.sh)"
HOSTS=/etc/hosts

IPS=$(curl -sS --max-time 15 "https://dns.google/resolve?name=${DOMAIN}&type=A" \
  | python3 -c 'import json,sys; d=json.load(sys.stdin); print("\n".join(r["data"] for r in d.get("Answer",[]) if r.get("type")==1 and r["data"].replace(".","").isdigit()))')

[ -z "$IPS" ] && { echo "DoH 无结果,跳过" >&2; exit 1; }

# 删掉上次的托管区块,再追加新的
python3 - "$HOSTS" "$MARK" "$IPS" <<'PY'
import sys, re
hosts, mark, ips = sys.argv[1], sys.argv[2], sys.argv[3].split()
out, skip = [], False
for ln in open(hosts).read().splitlines():
    if ln.strip() == mark:
        skip = True
        continue
    if skip and re.match(r'^\s*\d+\.\d+\.\d+\.\d+\s+agentrouter\.org\s*$', ln):
        continue
    skip = False
    out.append(ln)
while out and out[-1] == "":
    out.pop()
out += ["", mark] + [f"{ip} agentrouter.org" for ip in ips]
open(hosts, "w").write("\n".join(out) + "\n")
print("pinned:", ", ".join(ips))
PY

然后挂到 crontab 每天跑一次(记得先备份 /etc/hosts):

chmod +x /root/.hermes/scripts/pin-agentrouter-dns.sh
cp /etc/hosts /etc/hosts.bak
(crontab -l; echo "17 5 * * * /root/.hermes/scripts/pin-agentrouter-dns.sh >/dev/null 2>&1") | crontab -

顺带提一句:如果你在内网用自建 DNS(AdGuard、Pi-hole、公司网关),更彻底的做法是去上游把这条 A 记录修正掉。改 /etc/hosts 只是单机层面的兜底。

第二步:认出“UA 白名单网关”这个套路

网络通了之后,curl 依然返回 unauthorized client detected。我一开始以为 key 坏了,但换一个明显瞎编的 key 去请求,返回的还是一模一样的文案:

# 假 key 和真 key 的响应完全相同 —— 说明它压根没在验证 key
curl -sS https://agentrouter.org/v1/models \
  -H "Authorization: Bearer sk-abcdefg123"

这就说明拒绝发生在认证之前,拦你的是客户端指纹。这类中转站(Codex 系渠道的聚合站尤其多)会校验 User-Agent,只放行官方客户端的特征串,挡掉 SDK、脚本和爬虫。实测结果:

  • codex_cli_rs/0.48.0 ✅ 通过
  • claude-cli/1.0.60 (external, cli) ✅ 通过
  • OpenAI/Python 1.59.0 ❌ 401 unauthorized client
  • curl/8.5.0 ❌ 401 unauthorized client

而 Hermes 底层用的是 OpenAI Python SDK,默认 UA 就是 OpenAI/Python x.y.z,正好躺在黑名单里。日志里之所以显示成连接错误而不是 401,是因为 SDK 在 codex 流式模式下把这类失败也归到了连接异常,再加上 TTFB 超时保护,最终表现成“120 秒无响应”。

这就是为什么“换 key 没用、换网络没用”——问题在请求头。

第三步:确认接口路径 —— 只支持 /v1/responses

带上合法 UA 再打一次,报错变了,这次总算给了明确提示:

curl -sS https://agentrouter.org/v1/chat/completions \
  -A "codex_cli_rs/0.48.0" \
  -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"model":"gpt-6.1-sol","messages":[{"role":"user","content":"hi"}],"max_tokens":8}'

返回:

{
  "error": {
    "message": "模型 gpt-6.1-sol 不支持 /v1/chat/completions 接口,请改用 /v1/responses(OpenAI Responses API)。"
  }
}

OK,结论明确了:这个渠道的模型只能走 OpenAI Responses API,也就是 POST /v1/responses,参数结构是 input + stream: true 的 SSE 流,而不是老式的 messages。验证一下:

curl -sS -N https://agentrouter.org/v1/responses \
  -A "codex_cli_rs/0.48.0" \
  -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: text/event-stream' \
  -d '{
    "model": "gpt-6.1-sol",
    "input": [{"role":"user","content":[{"type":"input_text","text":"say hi"}]}],
    "stream": true
  }'

能看到 event: response.created、response.in_progress 一路往下推,就说明这条路是通的。

最终配置:三处改动搞定 Hermes 接入

Hermes 的 provider 配置在 ~/.hermes/config.yaml。三件事要做:添加 provider、声明 api_mode、全局注入请求头。

1. 添加 custom_providers 条目

custom_providers:
- name: agentrouter.org
  base_url: https://agentrouter.org/v1
  api_key: sk-你的密钥
  api_mode: codex_responses     # 走 Responses API,不是 chat_completions
  model: gpt-6.1-sol
  discover_models: false        # 关键:别让 Hermes 自动探测模型列表
  models:
  - gpt-6.1-sol
  - gpt-5.6-sol
  - gpt-6-astra

几个字段为什么这么填:

  • api_mode: codex_responses:告诉 Hermes 用 Responses API 的报文格式和 SSE 解析器。填 chat_completions 会直接撞上上面那个“不支持该接口”。
  • discover_models: false:Hermes 启动时会用 hermes-cli/x.y 的 UA 去探 /v1/models,UA 网关会把它打回 401,导致模型选择器整个报错。写死模型列表就绕过了这一步。
  • models 用列表:每个用户实际能用的模型不同,列出你确认过的即可。

2. 注入 codex 客户端身份头(全局)

在 config.yaml 顶层 model: 块里加 default_headers。这是 Hermes 提供的“用户自定义请求头”入口,会合并到所有由 OpenAI SDK 构造的客户端上——主对话、标题生成、上下文压缩、视觉调用都生效,而且优先级高于 SDK 默认值。

model:
  default: admin/DeepSeek-V4.1-Flash-1017
  provider: custom:modelhub-api.streamcomputing.com
  default_headers:
    User-Agent: codex_cli_rs/0.48.0 (Hermes Agent)
    originator: codex_cli_rs

改完记得验证一下配置文件还能被 YAML 正常解析,别让一个缩进错误把整个 Hermes 搞崩:

python3 -c "import yaml;c=yaml.safe_load(open('/root/.hermes/config.yaml'));print(c['model'])"

注意这是全局头,不只是给这个 provider 用的。所以改完必须回头测一遍原来的默认 provider 还能不能用(我这次测了 modelhub 和火山 ark,两个都正常返回)。如果某个 provider 因为 UA 被改而挂掉,那就说明它也在做指纹校验,只能靠切换 provider 时用不同的头来解决。

3. 确认配置解析正确

Hermes 有内置的运行时解析函数,可以直接问它“这个 provider 到底会被怎么调”:

/usr/local/lib/hermes-agent/venv/bin/python -c "
from hermes_cli.runtime_provider import resolve_runtime_provider
r = resolve_runtime_provider(requested='custom:agentrouter.org')
print(r)
"

重点看 api_mode 是不是 codex_responses、base_url 是不是带 /v1。

端到端验证:跑一次真实对话

curl 通了不代表客户端通了(报文结构、SSE 解析、工具调用格式都可能有差异),所以最后一定要用真实对话验证:

# 指定 provider 跑一次
hermes chat -q "Reply with exactly: OK" \
  --provider custom:agentrouter.org --model gpt-6.1-sol -Q

# 再测同渠道的其它模型
hermes chat -q "Reply with exactly: OK2" \
  --provider custom:agentrouter.org --model gpt-5.6-sol -Q

# 最后不带参数,确认默认 provider 没被 default_headers 影响
hermes chat -q "Reply with exactly: DEFAULT-OK" -Q

三条都正常返回,才算真的修好了。

踩坑总结:接第三方中转站的排查顺序

把这一套抽象出来,下次遇到同类问题按这个顺序走,基本不会绕远路:

  • 先分层定位:首页能不能开 → 决定查网络还是查认证。别一上来就换 key。
  • DNS 用 DoH 对一遍:getent hosts 与 dns.google/resolve 结果不一致,就是解析被劫持,改 hosts 或改上游 DNS。
  • 用假 key 做对照实验:假 key 和真 key 报错一致 → 服务端没在验 key,拦的是客户端指纹。
  • 试几个 UA:codex_cli_rs/*、claude-cli/* 是这类站最常放行的两个;SDK 和 curl 通常被拒。
  • 读报错里的路径提示:很多站会明确说“请改用 /v1/responses”,中文报错别急着跳过。
  • 改完全局头要回归测试:model.default_headers 是全局生效的,别修好一个弄挂另一个。

一点补充:这种方式合适吗?

最后说句实话。伪造客户端 UA 去接中转站,本质上是在绕过对方的风控策略,属于“能用但不稳”的方案:对方随时可以升级指纹校验(TLS 指纹、header 顺序、x-stainless-* 系列头都会看),也可能因为检测到异常直接封号。我自己是把它当备用渠道,主力还是走正规 API。

如果你只是想让 Hermes 有个便宜的第二选择,这套配置够用;如果是生产环境依赖,建议老老实实买官方额度,别把稳定性赌在别人的风控策略上。

好了,就这些。整个排查最花时间的不是配置本身,而是先搞明白“这三条报错分别是谁在拒绝我”。希望这篇能帮你把这一晚上省下来。

发表回复

后才能评论