Codex API URL 少写 /v1:一次被误判成校园网和反向 SSH 的排障复盘

一次把 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.0127.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-Typetext/html,实际是 New API 的前端页面。
  • https://botcf.com/v1/models 返回 200 OKContent-Typeapplication/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”。但回头看,这个分支不应该覆盖主线证据:

  1. 客户端配置确实少 /v1
  2. /models/v1/models 的内容类型差异已经说明根路由和 API 路由不是一回事。
  3. 用户最终修正 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:220.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 上。

这次给我的经验是:

  1. 复杂远程排障里,先验证协议合约,再验证网络通道。
  2. “能连上域名”不等于“连到了 API”。
  3. 流式错误不一定是长连接问题,也可能是路径错导致根本没有流。
  4. 反向 SSH 是很好的远程手术工具,但不能让工具本身变成排障主线。
  5. OpenAI-compatible 服务的 base_url 要作为一级检查项,尤其是 /v1

最后这个问题有点好笑,也很真实:两个人都盯着代理、校园网、长连接看了半天,结果配置里少了四个字符。

/v1,四个字符,够买一整晚排障。

好像解决了一个先有鸡还是先有蛋的问题

最后花了很大的手段ssh过去 还是用他自己要用的api解决了这个api没法用的问题

堪比钟离假死 :thinking: