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 clientcurl/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 有个便宜的第二选择,这套配置够用;如果是生产环境依赖,建议老老实实买官方额度,别把稳定性赌在别人的风控策略上。
好了,就这些。整个排查最花时间的不是配置本身,而是先搞明白“这三条报错分别是谁在拒绝我”。希望这篇能帮你把这一晚上省下来。






