为 Codex 桌面版配置第三方中转

在 Linux 上把 ChatGPT 桌面版(内置 Codex)接入自己搭建的 OpenAI 兼容中转,一共四步:让 codex 命令可用、补齐认证、把 provider 导进正确分类、用一个本地 $ref 展开代理收尾。下面是可直接照做的完整配置流程。

看得见的凭证与路径,未必落在想去的那条上,回读验证胜过反复重试

为 Codex 桌面版配置第三方中转

一、目标与效果

让本机的 Codex 走自建中转,而不是 OpenAI 官方。工具链是 ChatGPT 桌面版(Linux deb,版本 26.901.20858,内置 codex-cli 0.153.0-alpha.5)加 CC-Switch(v3.20.1)做 provider 切换。

达成后:CLI 和 GUI 都能发消息、调工具,数据经中转转发。全流程不改动任何系统文件,$ref 问题也只在本地代理处消化。

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

二、第一步:让 codex 命令可用

deb 包里其实已经带上了完整的 CLI 二进制,只是包根目录没导出 /usr/bin/codex 软链。直接补一条软链指向捆绑二进制即可:

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

验证:

1
codex --version   # -> codex-cli 0.153.0-alpha.5

不要照系统提示去 snap install codex,那会装出另一个无关的 Codex。

三、第二步:补双份认证(CLI 与 GUI)

Codex 的 CLI 和桌面 GUI 走两条不同的认证读取路径:

认证来源
CLI ~/.codex/config.toml 里的 experimental_bearer_token
GUI ~/.codex/auth.json,靠它判断”是否已登录”

CC-Switch 只把密钥写进了自己的 SQLite,config.toml 是全的,但 auth.json 没生成——于是 CLI 能用、GUI 全程未登录。

补齐 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
codex login status   # -> Logged in using an API key - sk-****

关于残留的官方账号 401

补完 auth.json 后,日志里可能还残留一批指向 chatgpt.com401(/backend-api/settings/userwham/usage 等)。它们是官方账号态接口(用户设置、用量统计),只认 ChatGPT 的 OAuth session,第三方中转的 API key 天然无效。

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

四、第三步:把 provider 导入正确的应用分类

CC-Switch 通过深链 ccswitch://v1/import 导入 provider。导入时要注意:app= 字段决定它落到 Claude 还是 Codex,不一定是你想去的那个。导入后务必回读确认:

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

如果 5 次全落在 app=Some("claude"),说明 provider 被导成了 Claude 的,Codex 侧还是空的。此时要么在 UI 里手动建一个 app_type='codex' 的 provider,要么点之前先看清深链里的目标应用。

导入类操作一定要回读确认落到哪儿了,不要以”点了没报错”为准。

另外留意一个副作用:如果 Claude 侧的 provider 意外写入,config.tomlshell_environment_policy.set 可能注入了 ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL。Codex 用不到这几项,但它们会随 Codex 启动的每个子进程可读,属于凭证扩散面,应当从配置里删掉。

五、第四步:用一个本地 $ref 展开代理收尾

Codex GUI 发消息时,工具 schema 由 zod 在运行时动态生成,包含 $ref` / `$defs。部分中转的 OpenAI 兼容层不解析 $ref,会返回:

1
Invalid JSON schema: Unresolvable $ref.

这不是配置文件能规避的(工具 schema 是运行时自动生成,且宿主用命令行 -c 注入 MCP,优先级最高)。最稳妥的做法是在流量出本机之前把 $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 等),否则转发长度不一致

六、验证

  1. CLI 先验证:codex exec "reply with exactly: OK",应看到模型经自定义 provider 回复。
  2. GUI 发一条带工具的请求:模型回话、读文件、跑命令,链路应全部打通。
  3. 若之前报过 $ref,用对照请求确认代理生效:
1
2
[直连中转  ] HTTP 400  Invalid JSON schema: Unresolvable $ref.
[经本地代理] HTTP 200 -> 'OK'
  1. 流式确认未被缓冲(首字节应远早于总耗时)。

七、日常注意

  • base_url 指向 127.0.0.1:8788,本地代理是一个普通进程,注销或重启后就没了。届时症状变成”连接被拒”。要么做成开机自启,要么明确知道这一点。
  • $ref` 问题的根本解是让中转在兼容层展开它(`$ref / $defs 是 JSON Schema 标准,OpenAI 官方 API 正常支持)。本地代理只是止血,值得给中转提个 issue。

附:踩坑与原理补充 / 速查表

这套配置之所以每个步骤都要”点一下回读验一下”,是因为每一步都可能静默失败或落入意料之外的路径。下面是关键原理与对应症状。

为什么用 dpkg -L 而不是 which

发行版包管理器、npm 全局、nvm 各版本、snap、以及应用自带的捆绑二进制是五个独立的安装位面。which 只看 PATH,而应用自带的二进制不进来。dpkg -L <pkg> 才能看清包到底装了什么。

为什么要补两份认证

CLI/GUI 认证读取路径不同,config.toml 一份不够。auth.json 缺失会让 GUI 全程未登录,而 CLI 因 experimental_bearer_token 不受影响——这种”一样配置、A 能用 B 不能用”的不对称,恰恰是最强的排查线索。

为什么 $ref 关不掉

两点叠加:工具 schema 由 zod 在运行时动态生成(源码里搜不到,不代表不会产生);宿主用命令行 -c 注入 MCP (startup_timeout_sec=10 异步注册),**优先级高于 config.toml**。所以”关插件/改配置”都不成立,只能在流量层面拦。

“第一轮成功、之后全失败”意味着什么

工具列表是动态增长的:第一轮请求发出时 MCP 尚未注册完 → 工具集扁平 → 200;几秒后带 $ref 的工具 schema 加入 → 之后每轮 400。这种模式几乎总指向异步初始化,别当偶发网络抖动。

快速排查表

现象 真实原因 定位方法
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
  • 版权声明: 本博客所有文章除特别声明外,均采用 Apache License 2.0 许可协议。转载请注明出处!
  • © 2019-2026 guoben

微信