一次把 Codex API URL 少写 /v1 排成校园网和反向 SSH 的事故复盘
这次的问题最后一句话就能说完:
朋友那台 Windows 上的 Codex 配置里,OpenAI-compatible API 的
base_url写成了https://botcf.com,少了标准 API 前缀/v1。
正确方向应该是让 Codex 打到https://botcf.com/v1/...这一类 API 路径。
但我们实际花掉的时间远不止“一眼看配置”。中间绕了很大一圈:先怀疑代理、校园网、IPv6、长连接、Windows OpenSSH、端口占用、反向 SSH,再通过 TeamViewer 在对方机器上反复看日志、抓包、改 SSH 入口。最后真正的根因却是最朴素的路径错误。
这篇记录一下完整过程,也算提醒自己:排远程 AI 客户端故障时,第一优先级不是先把网络拓扑折腾漂亮,而是先做最小 API 合约检查。
背景
场景大概是这样:
- 朋友在 Windows 上跑 Codex。
- API 上游走的是我们自己维护的 OpenAI-compatible 服务。
- 他的 Codex 一直不能正常完成请求,看起来像流式响应很快断掉。
- 我这边最初没法直接操作他的电脑,只能通过他用 TeamViewer 控制、在 PowerShell 里贴命令和日志。
- 为了让我能直接诊断,后来又折腾了一条从他 Windows 到我 Mac 的反向 SSH,让我从本机
127.0.0.1:<端口>反连进他的 Windows OpenSSH。
最终确认,核心配置是:
model_provider = "custom"
model = "gpt-5.5"
[model_providers.custom]
wire_api = "responses"
requires_openai_auth = true
base_url = "https://botcf.com" # 这里少了 /v1
问题就埋在最后这一行。
第一阶段:先看 Windows OpenSSH,到底有没有入口
一开始我们看的是 Windows OpenSSH 的状态。OpenSSH/Operational 里基本只有:
sshd: Server listening on 0.0.0.0 port 22.
sshd: Server listening on :: port 22.
中间还有一条外部连接的 banner exchange ... Connection aborted,但没有能解释 Codex 失败的认证日志。也就是说,服务是起来了,但光看这个日志看不出“为什么 Codex 不行”。
然后查本机 22 端口监听:
Get-NetTCPConnection -LocalPort 22 -State Listen |
Select-Object LocalAddress,LocalPort,OwningProcess
这里出现了第一个干扰项:同一个端口上不只有 OpenSSH。Windows 上 0.0.0.0:22 / :::22 是 sshd,但 127.0.0.1:22 / ::1:22 竟然被 Steam++ 占了。
这个现象很容易把人带偏。你以为自己测的是 Windows sshd,实际本机 loopback 可能先打到另一个进程。后来把 Steam++ 关掉后,ssh 127.0.0.1 才进入真正的 Windows OpenSSH。
这里的经验是:Windows 上看 sshd listening 不够,还要看每个 LocalAddress 对应的 OwningProcess。尤其是 0.0.0.0 和 127.0.0.1 可以不是同一个进程。
第二阶段:怀疑校园网和 IPv6,开始抓包
因为朋友在校园网环境里,我们很自然地怀疑:
- 校园网是不是挡了入站 SSH?
- IPv6 能不能从外部打进去?
- 长连接是不是被网关切了?
- 代理或 Cloudflare 路径是不是影响 SSE?
朋友机器上的 ipconfig 显示 PPP 适配器有公网 IPv4 和全球 IPv6,默认网关是 IPv6 link-local。我们用 PowerShell 和 pktmon 抓了一轮,能看到从校园网 IPv6 地址往我 Mac 的 IPv6 地址发 22 端口流量,并且有 ACK / payload。
这说明至少“从他电脑主动 SSH 到我机器”这条路是通的。也就是说,如果不做入站,改成他主动连我,然后在 SSH 上开反向端口转发,是可行的。
这里其实已经能得出一个网络层结论:校园网可能不适合让外部直接打进他机器,但并不妨碍他主动连出来。于是方案从“我 SSH 进他机器”变成“他 SSH 到我机器,再把他本机 sshd 反向转发给我”。
第三阶段:反向 SSH,一路踩端口和 host key
反向 SSH 的思路是:
朋友 Windows sshd: 127.0.0.1:22 或 [::1]:22
^
| 通过 SSH -R 暴露
|
我 Mac 上的 127.0.0.1:2223 / 2224
也就是让朋友那边执行类似:
ssh -6 `
-o PubkeyAuthentication=no `
-o PreferredAuthentications=password `
-o ExitOnForwardFailure=yes `
-o ServerAliveInterval=30 `
-o ServerAliveCountMax=3 `
-N `
-R 127.0.0.1:2224:[::1]:22 `
<my-user>@[我的IPv6地址]
中间踩了几个小坑。
第一个是远程监听端口冲突:
Error: remote port forwarding failed for listen port 2222
这通常就是我这边 2222 已经被占,或者之前残留转发还没断干净。换成 2223 / 2224 后继续。
第二个是 Windows 本地 host key 混乱。朋友本机 ssh 127.0.0.1 时出现:
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!
Offending ED25519 key in ... known_hosts
本来应该用:
ssh-keygen -R 127.0.0.1
但他的 known_hosts 后面有几行格式坏了,导致 ssh-keygen -R 不愿意改文件:
known_hosts is not a valid known_hosts file.
Not replacing existing known_hosts file because of errors
最后直接把旧 known_hosts 备份移走,让 SSH 重新记录 host key。
第三个是密码登录。Windows 账户此前显示“不需要密码”,但 SSH 密码登录需要一个真实密码。于是临时开了一个短期密码用于排障。这个地方公开写文章时不应该保留具体密码,只需要记住:远程协助结束后,这种临时密码应该及时撤掉或改掉,最好最终换成公钥登录。
最后反向转发成功,关键日志是:
Authenticated to [我的IPv6地址] using "password".
Remote connections from 127.0.0.1:2224 forwarded to local address ::1:22
remote forward success for: listen 127.0.0.1:2224, connect ::1:22
forwarding_success: all expected forwarding replies received
之后我这边就可以用:
ssh -p 2224 \
-i ~/.ssh/<key> \
-o HostKeyAlias=<friend-windows-via-reverse-tunnel> \
<windows-user>@127.0.0.1
直接进入朋友的 Windows OpenSSH。
第四阶段:真正上机以后,才开始查 Codex
通过反向 SSH 进去以后,又踩了一个小坑:Windows 的默认远程 shell 不是类 Unix shell。最开始用:
hostname; uname -a; whoami; pwd
这套命令会被 Windows shell 吃得很奇怪。后来改成 PowerShell,并且为了避免多层引号把脚本吃坏,使用 -EncodedCommand 或 stdin 喂 PowerShell 脚本。
拿到的基础信息包括:
- Windows 11
- Codex 来自 npm 全局安装路径
codex-cli 0.133.0- Node
v24.16.0 - npm
11.13.0 - 配置目录在
%USERPROFILE%\.codex auth.json里有 API key- 日志持续写入,说明不是“客户端完全没跑起来”
随后读 config.toml,关键内容是:
model_provider = "custom"
model = "gpt-5.5"
model_reasoning_effort = "medium"
disable_response_storage = true
[model_providers.custom]
name = "custom"
wire_api = "responses"
requires_openai_auth = true
base_url = "https://botcf.com"
experimental_bearer_token = "<redacted>"
一眼看过去最扎眼的就是 base_url。
我们自己的正常配置长期都是类似:
base_url = "https://botcf.com/v1"
但当时我们没有第一时间抓住它。因为前面刚经历过校园网、IPv6、Steam++ 占端口、known_hosts 坏行、反向转发掉线,人脑已经被“网络问题”这条叙事带走了。
第五阶段:用最小 HTTP 请求把问题钉死
真正把问题钉死的是同机 HTTP 探测。
先看 DNS 和 TCP:
Resolve-DnsName botcf.com
Test-NetConnection botcf.com -Port 443
结果显示 DNS 能解析,443 能连,源地址走校园网的 IPv6 出口。网络基本通。
然后对比两个路径:
GET https://botcf.com/models
GET https://botcf.com/v1/models
差异非常关键:
https://botcf.com/models返回200 OK,但Content-Type是text/html,实际是 New API 的前端页面。https://botcf.com/v1/models返回200 OK,Content-Type是application/json,这才是 API。
这就解释了为什么 Codex 会表现得像“流式响应断了”:
Codex 配了:
base_url = "https://botcf.com"
wire_api = "responses"
于是它会去打类似:
POST https://botcf.com/responses
但这个路径不是 OpenAI-compatible API。服务端可能返回 HTML、404、重定向或某种前端路由响应。Codex 期待的是 Responses API 的 JSON / SSE 事件流,结果拿到的不是这个协议,于是日志里就会出现类似:
stream disconnected before completion
当时我们还测过 https://botcf.com/v1/responses,结果又看到过一条迷惑性错误:
{"error":{"message":"Invalid URL (POST /v1/v1/responses)"}}
这条一度把我带向“是不是服务端上游又多拼了一次 /v1”。但回头看,这个分支不应该覆盖主线证据:
- 客户端配置确实少
/v1。 /models和/v1/models的内容类型差异已经说明根路由和 API 路由不是一回事。- 用户最终修正 URL 后问题解决,说明最先该修的是客户端 API base。
这里的重点不是“所有服务端都一定只差 /v1”,而是:遇到 OpenAI-compatible 客户端问题,必须用最小请求验证当前配置拼出来的真实 URL 到底是不是 API。
为什么我们会被带偏
回看这次排障,最大的浪费不是技术上不会,而是问题叙事先入为主。
1. 症状长得太像网络问题
“流式响应很快断”“Codex 重试多次”“校园网”“IPv6”“代理”“Cloudflare”这些词放在一起,天然就会让人怀疑长连接和网络路径。
但 OpenAI-compatible 客户端里,很多协议错误也会伪装成网络错误。比如:
- HTML 被当成 SSE 读;
- 404 JSON 被当成流读;
- 反代返回了登录页;
- base path 少一段;
- upstream 又拼错 path。
客户端最后只知道“我没有等到合法完成事件”,于是报出来就像断流。
2. 我们先解决了“如何控制机器”,而不是“请求到底打到哪里”
远程控制确实必要,但它不应该取代最小诊断。
更快的第一步其实应该是让对方在 PowerShell 跑:
curl.exe -i https://botcf.com/models
curl.exe -i https://botcf.com/v1/models
只要看到第一个是 HTML、第二个是 JSON,就能立刻怀疑 base_url。
3. 反向 SSH 太有技术吸引力
反向 SSH 是好工具,也确实帮我们最终拿到了机器。但它很容易让人进入“把通道打通”的工程状态:端口、host key、密码、公钥、IPv6、监听地址、127.0.0.1 vs ::1,每个都能展开。
这些问题都真实存在,但它们不是根因。
这类时候要提醒自己:控制通道只是为了拿证据,不是目标本身。通道一通,马上回到最小证据:配置、请求、响应、日志。
这次反向 SSH 仍然留下了有用经验
虽然根因不是网络,但这次反向 SSH 过程并不是白费。它至少沉淀了几个实战点。
Windows 上 22 端口要看具体地址
不要只看“22 在监听”,要看:
Get-NetTCPConnection -LocalPort 22 -State Listen |
Select-Object LocalAddress,LocalPort,OwningProcess
再用:
Get-Process -Id <pid> | Select-Object Id,ProcessName,Path
确认 127.0.0.1:22、::1:22、0.0.0.0:22、:::22 到底是不是同一个 sshd。
反向转发优先绑远端 localhost
如果只是让我自己从 Mac 本机连,不需要把端口暴露到公网:
-R 127.0.0.1:2224:[::1]:22
这比 -R 0.0.0.0:2224:... 更安全。服务器上只有本机能连这个反向端口。
127.0.0.1 和 ::1 都要试
Windows 上经常出现 IPv4 loopback 和 IPv6 loopback 行为不一致,尤其还有其他软件占端口时。
这次最后更稳的是:
127.0.0.1:2224 -> [::1]:22
host key 乱了别硬怼
如果 ssh-keygen -R 因为 known_hosts 文件损坏失败,先备份旧文件再重建,比手动在坏文件里继续改更可靠。
临时密码要有生命周期
为了排障打开密码登录、设置临时密码可以理解,但这不是最终状态。远程协助结束后应该:
- 改回强密码或禁用密码登录;
- 上公钥;
- 清掉不必要的反向转发;
- 检查
authorized_keys和 SSH 服务配置; - 如果贴过日志,确认没有泄漏 key / token。
以后遇到类似问题,我会按这个顺序排
这次之后,我会把 OpenAI-compatible / Codex 客户端故障的第一轮检查固定成这样。
1. 先读配置
重点看:
model_provider
model
wire_api
base_url
requires_openai_auth
尤其是 base_url:
- OpenAI-compatible 通常应该带
/v1; - 不要凭域名猜;
- 不要只看服务首页能打开;
- 不要把管理后台、前端首页和 API base 混在一起。
2. 用同一台机器、同一把 key 做最小请求
至少测:
curl -i "$BASE/models"
curl -i "$BASE/responses"
如果不确定 base,就同时测:
curl -i "https://example.com/models"
curl -i "https://example.com/v1/models"
看三件事:
- HTTP 状态码;
Content-Type;- body 是 JSON 还是 HTML。
只要 /models 返回 HTML,就不要继续讨论 SSE 长连接。那是路径错了。
3. 再看客户端日志
日志里的 stream disconnected before completion 很有用,但它不是根因。它只是告诉你“客户端没读到协议预期的完成事件”。
下一步要问:
- 它实际请求的 path 是什么?
- 返回是不是 SSE?
- 里面有没有
response.completed? - 是否拿到了登录页、404 JSON、HTML 前端页?
4. 最后才扩大到网络路径
只有当最小 API 请求在正确 URL 上仍然异常,再去查:
- DNS;
- IPv4 / IPv6;
- Cloudflare;
- 校园网;
- 代理;
- MTU;
- 长连接;
- 防火墙;
- 服务端 upstream。
这个顺序能少走很多路。
总结
这次最有戏剧性的地方是:我们为了一个少写 /v1 的配置错误,先后检查了 Windows 管理员权限、OpenSSH 日志、端口监听、Steam++ 占用、校园网 IPv6、pktmon 抓包、known_hosts 损坏、密码登录、反向 SSH、PowerShell 引号、Codex 安装来源和日志。
每一步都不是无意义的,它们都可能是真问题;但它们都不是这次的根因。
真正应该最先问的是:
这个客户端最终拼出来的 URL,是不是 OpenAI-compatible API URL?
如果当时第一分钟就跑:
GET https://botcf.com/models -> HTML
GET https://botcf.com/v1/models -> JSON
我们大概率不会把时间花在校园网和反向 SSH 上。
这次给我的经验是:
- 复杂远程排障里,先验证协议合约,再验证网络通道。
- “能连上域名”不等于“连到了 API”。
- 流式错误不一定是长连接问题,也可能是路径错导致根本没有流。
- 反向 SSH 是很好的远程手术工具,但不能让工具本身变成排障主线。
- OpenAI-compatible 服务的
base_url要作为一级检查项,尤其是/v1。
最后这个问题有点好笑,也很真实:两个人都盯着代理、校园网、长连接看了半天,结果配置里少了四个字符。
/v1,四个字符,够买一整晚排障。