把 Codex 桌面版接到第三方中转:五个叠加故障的排查记录

把 ChatGPT 桌面版(内置 Codex)接到公司自建的 OpenAI 兼容中转,看似只是改一行 base_url,实际连续撞上五个互相掩盖的故障:deb 包只注册 GUI 入口而不导出 CLI 软链、切换工具写了 config.toml 却没写 auth.json 导致 CLI 可用而 GUI 全程 401、深链导入默认落到 Claude 分类而从未进入 Codex、Electron 用命令行 -c 注入 MCP 服务使配置文件里的插件开关永久失效,以及中转的兼容层不解析 JSON Schema 的 $ref` 而 Codex 的工具 schema 由 zod 在运行时动态生成必然带 `$ref。本文记录每层故障的现象、证据链与定位过程,包含两次被证伪的错误判断,最后给出一个不改动任何系统文件的本地 $ref 展开代理。

每层故障都在掩盖下一层,证伪比证实更省时间

把 Codex 桌面版接到第三方中转:五个叠加故障的排查记录


一、背景

目标很简单:让本机的 Codex 走公司的 OpenAI 兼容中转,而不是 OpenAI 官方。工具链是 ChatGPT 桌面版(Linux deb,版本 26.901.20858,内置 codex-cli 0.153.0-alpha.5)加 CC-Switch(v3.20.1)做 provider 切换。

初始症状只有一句话:**”装了 Codex 但不能调用”**。

这类描述的信息量接近于零,但它掩盖的是五层独立故障。这五层的关键性质是互相掩盖:每修好一层,暴露出的下一层症状完全不同,看起来像”又坏了个新的”。整个过程里我有两次判断被后续证据推翻,这两次证伪反而是定位效率最高的环节——所以下文一并保留。

约定:文中中转地址统一记为 https://relay.example.com,模型记为 <org>/kimi-k3,密钥一律 sk-****

二、陷阱一:二进制存在,但包不导出 CLI 入口

第一层是最容易误判的,因为它长得像”没装成功”。

1
2
3
4
5
$ which codex
codex not found
$ codex --version
找不到命令 "codex",但可以通过以下软件包安装它:
sudo snap install codex

系统提示装 snap,这是个误导——顺着它走会装出第二个无关的 Codex。真实情况是包已经装了,而且二进制完好:

1
2
3
4
5
6
$ dpkg -L chatgpt | grep -E "bin/|codex$"
/usr/lib/chatgpt/codex-launcher
/usr/lib/chatgpt/resources/codex ← 258M,ELF 可执行

$ /usr/lib/chatgpt/resources/codex --version
codex-cli 0.153.0-alpha.5

包只注册了一个入口,而它指向 GUI:

1
2
3
4
5
/usr/bin/chatgpt -> ../lib/chatgpt/codex-launcher

$ cat /usr/lib/chatgpt/codex-launcher
#!/bin/sh
exec "$(dirname "$(readlink -f "$0")")/ChatGPT" "$@" ← 拉起 Electron

deb 从来没有创建 /usr/bin/codex 这不是安装损坏,是这个包的设计:它是桌面应用,CLI 只是内部实现细节。

顺带一提,~/.oh-my-zsh/plugins/codex 那个补全插件开头就是:

1
2
3
if (( ! $+commands[codex] )); then
return
fi

所以它一直静默跳过,不会给任何提示。

解法:补一个软链即可。

1
sudo ln -sf /usr/lib/chatgpt/resources/codex /usr/local/bin/codex

排查要点

判断”装没装”不要只看 which。发行版包管理器、npm 全局、nvm 各版本、snap、以及应用自带的捆绑二进制是五个独立的安装位面。dpkg -L <pkg>which 可靠得多。

三、陷阱二:写了 config.toml,没写 auth.json

补完软链,CLI 立刻可用:

1
2
3
$ codex exec "reply with exactly: OK"
model: <org>/kimi-k3 provider: custom
codex: OK

但 GUI 打开后全程未登录态。 日志里是清一色的 401:

1
2
desktop_fetch_auth_401  hadToken=false  skipRetryReason=no_token_attached
GET relay-backend/settings/user → 401 "Unauthorized - Access token is missing"

同一份 ~/.codex/config.toml,CLI 能用、GUI 不能用——这个不对称是关键线索。

原因在于两者的认证读取路径不同:

认证来源
CLI config.toml 里的 experimental_bearer_token,直接使用
桌面 GUI ~/.codex/auth.json,判断”是否已登录”

而 CC-Switch 把密钥存在自己的 SQLite 里:

1
"auth": { "OPENAI_API_KEY": "sk-****" }

它把 config.toml 写得很完整,却**没有生成 auth.json**。CLI 因为能直接吃 experimental_bearer_token 而不受影响,恰好掩盖了这个缺失。

解法:补 auth.json。注意不要把密钥放进命令行——argv 对同机其他用户可见,应当从源头程序化读取:

1
2
3
4
5
6
7
8
9
10
11
import sqlite3, json, os

c = sqlite3.connect('file:~/.cc-switch/cc-switch.db?mode=ro', uri=True)
row = c.execute(
"select settings_config from providers where app_type='codex' and is_current=1"
).fetchone()
key = json.loads(row[0])['auth']['OPENAI_API_KEY']

fd = os.open('~/.codex/auth.json', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w') as f:
json.dump({'OPENAI_API_KEY': key}, f)

验证:

1
2
$ codex login status
Logged in using an API key - sk-****

一个必须区分的细节

补完 auth.json 后,401 并没有全部消失。剩下的是这些:

1
2
3
5 × GET /backend-api/settings/user
2 × GET /backend-api/wham/usage
1 × GET /backend-api/wham/tasks/list

全部指向 chatgpt.com账号态接口(用户设置、用量统计、云端任务)。这些只认 ChatGPT 的 OAuth session,第三方中转的 API key 在这里天然无效。

它们和对话链路是分开的:对话走 app-server → 自定义 provider → 中转,不经过 chatgpt.com。所以这批 401 属于预期噪音,只要不登录官方账号就会一直刷。

排查时把”预期噪音”和”真实故障”分开,否则会一直追一个永远修不好的东西。区分方法很朴素:看 401 打的是哪个 host、哪条路径

四、陷阱三:深链导入默认落到另一个应用分类

CC-Switch 的日志暴露了一个纯粹的操作陷阱:

1
2
$ grep -o 'app=Some([^)]*)' ~/.cc-switch/logs/cc-switch.log | sort | uniq -c
5 app=Some("claude")

点了 5 次 ccswitch://v1/import 深链,5 次全部注册成了 Claude 的 provider,一次都没进 Codex。数据库印证了这一点:

1
2
3
4
5
app_type   is_current  name
claude 1 TarsRoute ← 深链导入的
claude 0 TarsRouter ← 重复
claude 0 TarsRouter ← 重复
codex 1 TarsRouterKimi ← UUID 主键,手动建的

claude 下三个重复项都是时间戳主键(深链生成),codex 下那个是 UUID(手动创建)。也就是说:如果只点深链、不手动建,Codex 侧永远是空的。

这还有一个副作用——Claude 侧的写入串进了 Codex 的配置:

1
2
3
[shell_environment_policy.set]
ANTHROPIC_AUTH_TOKEN = "sk-****"
ANTHROPIC_BASE_URL = "https://relay.example.com"

shell_environment_policy.set 会把这两个变量注入 Codex 启动的每一个子进程。Codex 根本用不到 Anthropic 的凭证,但任何被 Codex 执行的脚本都能读到它。这是实实在在的凭证扩散面,应当删掉。

排查要点

导入类操作一定要回读确认落到哪儿了,不要以”点了没报错”为准。CC-Switch 这个深链把目标应用编码在 URL 里,而 UI 上不一定显著。

五、陷阱四:命令行 -c 覆盖配置文件(第一次误判)

前三层修完,GUI 终于能发消息了,但立刻报了个全新的错:

1
Invalid JSON schema: Unresolvable $ref.

这个错误的定位过程包含我第一次被证伪的判断,值得完整记录。

先做对了一件事:直接对中转发两个对照请求,把变量锁死。

1
2
3
4
5
ref_tool  = [{"type":"function","name":"t","parameters":{
"type":"object","properties":{"a":{"$ref":"#/$defs/X"}},
"$defs":{"X":{"type":"string"}}}}]
flat_tool = [{"type":"function","name":"t","parameters":{
"type":"object","properties":{"a":{"type":"string"}}}}]

结果干净利落:

1
2
[带 $ref ] HTTP 400 -> "Invalid JSON schema: Unresolvable $ref."   ← 与 UI 报错逐字一致
[不带 $ref] HTTP 200 OK

根因确认:**中转的 OpenAI 兼容层不解析 $ref**。

接着我去找是哪个插件产生的 $ref,grep 出唯一命中:

1
codex-app-tools : $ref found in 1 file(s)

于是判断是 codex-app-tools,提出关掉它:

1
2
[plugins."codex-app-tools@openai-bundled"]
enabled = false

这个判断错了两次。

错误 A——配置改了不生效。 重启后 app-server 的启动参数里那个 MCP 服务还在:

1
/usr/lib/chatgpt/resources/codex ... -c mcp_servers.codex_app={...codex-app-tools...}

Electron 是用命令行 -c 把它硬塞进去的,而 -c 的优先级高于配置文件。也就是说这条路从一开始就走不通,任何 config.toml 层面的开关都改不掉它。

错误 B——插件定位本身就是错的。 仔细看那 9 处 $ref 命中的内容:

1
2
3
4
// ajv 校验器内部
if (key !== "$ref" && RULES.all[key]) ...
// zod-to-json-schema 内部
if (ctx.target === "draft-2020-12") { result.$defs = defs; }

全是打包进去的第三方库代码,不是工具自己的 schema。grep 命中的是库实现,不是数据。

而最后那段恰恰揭示了真相:$ref` / `$defszod 在运行时动态生成的——当 schema 存在复用或递归结构时自动抽取。这意味着:

静态 grep 源文件无法判断一个插件会不会产生 $ref。文件里搜不到这个字符串的插件,运行时照样可能生成。

所以”只关一个插件”从方向上就不成立。

排查要点

grep 命中要区分代码数据。在打包过的 JS bundle 里搜 JSON Schema 关键字,绝大多数命中来自 ajv / zod / json-schema 这类库的实现代码,和业务 schema 无关。

六、陷阱五:异步注册导致”第一轮成功,第二轮失败”

这一层的现象非常有迷惑性,但它反而是最有价值的证据。

UI 上的表现是:

1
2
3
4
用时 5秒
我先探索一下这个博客项目的框架结构。 ← 模型正常回复
已读取文件运行了命令 ← 工具调用成功
Invalid JSON schema: Unresolvable $ref. ← 然后才炸

第一轮真的通了:模型回话、读文件、执行命令,整条链路全部打通。第二轮才失败。

原因是工具列表是动态增长的codex-app-tools 的 MCP 服务是异步启动的(启动参数里 startup_timeout_sec=10):

  1. 第一轮请求发出时,MCP 尚未注册完 → 工具集干净扁平 → 200
  2. 几秒后 MCP 注册完成,zod 生成的带 $ref 的 schema 加入工具列表
  3. 从此每一轮都 400

这个时序解释了”生效了一部分”这个乍看矛盾的描述,也反过来坐实了问题出在 MCP 注册进来的那批工具上。

排查要点

“第一次成功,之后都失败”这个模式,几乎总是指向异步初始化:某个组件在首次请求后才完成注册,改变了后续请求的形状。不要把它当成”偶发”或”网络抖动”。

七、解法:本地 $ref 展开代理

既然:

  • 中转不解析 $ref(上游缺陷,短期改不了)
  • $ref 由 zod 运行时生成(无法靠关插件规避)
  • 命令行 -c 优先级最高(无法靠配置规避)

那么唯一既彻底又不侵入的位置,是在流量出本机之前把 $ref 展开掉

起一个本地反向代理,把 base_url 指向它:

1
base_url = "http://127.0.0.1:8788"

它负责递归内联 tools 里所有 $ref`、剥掉 `$defs,再转发给真实中转。核心逻辑只有两个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def resolve_pointer(root, ref):
"""解析形如 '#/$defs/Foo' 的本地 JSON Pointer,外部引用返回 None。"""
if not isinstance(ref, str) or not ref.startswith("#"):
return None
cur = root
for raw in ref.lstrip("#").split("/"):
if raw == "":
continue
part = raw.replace("~1", "/").replace("~0", "~")
if isinstance(cur, dict):
if part not in cur:
return None
cur = cur[part]
elif isinstance(cur, list):
try:
cur = cur[int(part)]
except (ValueError, IndexError):
return None
else:
return None
return cur


def inline(node, root, stack=frozenset(), depth=0):
"""递归内联 $ref,并剥掉 $defs / definitions。"""
if depth > MAX_DEPTH:
return {"type": "object", "additionalProperties": True}
if isinstance(node, list):
return [inline(x, root, stack, depth + 1) for x in node]
if not isinstance(node, dict):
return node

ref = node.get("$ref")
if isinstance(ref, str):
if ref in stack:
# 递归结构无法完全展开,退化成宽松 object,避免无限膨胀
return {"type": "object", "additionalProperties": True}
target = resolve_pointer(root, ref)
siblings = {k: v for k, v in node.items() if k != "$ref"}
if target is None:
return inline(siblings, root, stack, depth + 1) if siblings else {}
expanded = inline(target, root, stack | {ref}, depth + 1)
if siblings and isinstance(expanded, dict):
merged = dict(expanded)
merged.update(inline(siblings, root, stack, depth + 1))
return merged
return expanded

return {k: inline(v, root, stack, depth + 1)
for k, v in node.items() if k not in ("$defs", "definitions")}

三个必须处理的边界:

  1. 递归结构(Node.child -> Node)——用 stack 做环检测,命中则退化为宽松 object,否则会无限膨胀
  2. 同级兄弟键({"$ref": ..., "description": ...})——展开后要合并,不能直接丢
  3. 解析失败——剥掉 $ref 保留其余约束,而不是整个丢弃

代理本身还有两点不能省:

  • 不能缓冲。Codex 走 SSE 流式,必须逐块 write + flush,并设 Accept-Encoding: identity 避免压缩打断流
  • 逐跳头部要过滤(Content-LengthTransfer-EncodingConnection 等),否则转发会出现长度不一致

验证

对照测试,同一个带 $ref 的请求:

1
2
[直连中转  ] HTTP 400  Invalid JSON schema: Unresolvable $ref.
[经本地代理] HTTP 200 -> 'OK'

流式确认没有被缓冲(首字节远早于总耗时):

1
2
HTTP 200 | content-type: text/event-stream
SSE 事件行数: 199 | 首字节延迟: 2.09s | 总耗时: 4.31s

这只是止血

必须说清楚:**根本解是让中转在兼容层展开 $ref`**。`$ref / $defs 是 JSON Schema 标准的一部分,OpenAI 官方 API 正常支持。中转不实现它,意味着任何用 zod 生成工具定义的客户端——Codex、Cursor、Cline——都会撞同一堵墙。本地代理只是把问题挡在自己机器上,该提的 issue 还是要提。

另外,本地代理是个普通进程,注销或重启后就没了。届时症状变成”连接被拒”,和本文所有故障都不一样。要么做成开机自启,要么明确知道这一点。

八、速查表

现象 真实原因 定位方法
which codex 找不到,提示装 snap deb 只注册 GUI 入口,不导出 CLI 软链 dpkg -L <pkg> 找捆绑二进制
CLI 可用但 GUI 全程 401 auth.json;CLI 走 experimental_bearer_token,GUI 走 auth.json codex login status
补完认证仍有 401 打向官方账号态接口(settings/userwham/*),API key 天然无效 看 401 的 host 与路径,与对话链路区分
切换工具”点了没反应” 深链导入默认落到别的应用分类 查工具自身日志的 app= 字段 + 回读数据库
改了 config.toml 不生效 宿主用命令行 -c 注入,优先级高于配置文件 ps 看进程完整启动参数
Invalid JSON schema: Unresolvable $ref` | 中转不解析 `$ref,而 zod 运行时生成 $ref` | 构造带/不带 `$ref 的对照请求
第一轮成功,之后全失败 异步组件(MCP)注册后改变了工具集形状 看组件启动时序与 startup_timeout_sec

九、方法论小结

这次排查真正省时间的不是任何一条命令,而是三个习惯:

一、对照实验优先于阅读源码。 $ref 那层,构造两个只差一个字段的请求、跑两次,五秒钟就锁死了根因;而我去 grep 二进制和 bundle 花的时间更长,还得出了错误结论。能用黑盒对照锁定的,不要先去读实现。

二、区分预期噪音与真实故障。 补完 auth.json 后残留的那批 401 会永远存在。如果不先按 host 和路径把它们归类掉,就会一直在修一个不该修的东西。

三、不对称现象是最强的线索。 “CLI 能用、GUI 不能用”,同一份配置、同一个二进制——这个不对称直接指向了两者认证读取路径的差异。凡是 A 能 B 不能,先去找 A 和 B 到底走了哪条不同的代码路径,比漫无目的看日志高效得多。

最后关于错误判断:本文保留了两次被证伪的推断(插件定位错误、配置开关无效)。它们不是流水账——第一次证伪推翻了”关插件”这个整体方向,第二次证伪直接催生了”必须在流量层面解决”的结论。排查里被推翻的假设,往往比被证实的假设更能收敛搜索空间。

  • 版权声明: 本博客所有文章除特别声明外,均采用 Apache License 2.0 许可协议。转载请注明出处!

扫一扫,分享到微信

微信分享二维码
  • © 2019-2026 guoben

微信