想解决的问题
我在 Codex 桌面 App 里用的是 ChatGPT 订阅。想在同一个模型菜单里再加上 DeepSeek,有几个要求:
- GPT 继续用我自己的订阅登录,不换成 API Key。
- DeepSeek 直接调官方 API,用我自己的 Key,不经过任何第三方中转。
- 不改 App 本身,坏了能一键退回原样。
最后的做法是:在本机 127.0.0.1 上跑一个很小的 HTTP 桥接,再给 App 一份合并后的模型目录。环境是 macOS,ChatGPT App 26.1007.x,内置 Codex CLI 0.162.0-alpha。
架构
Codex 桌面 App
│
└── 127.0.0.1:19427 本机桥接
├── GPT 模型 → chatgpt.com/backend-api/codex → 原订阅登录(透传)
├── deepseek-flash → api.deepseek.com → 自己的 DeepSeek Key
└── deepseek-v4-pro → api.deepseek.com → 自己的 DeepSeek KeyApp 从合并后的模型目录读到菜单,所有 Responses 请求都发到本机桥接。桥接只看请求体里的 model 字段,决定发往哪个上游。
需要说清楚这个方案没做的事:
- 没给 App 原生代码加 Provider。
- GPT 请求不会用 DeepSeek Key。
- 没把 Responses 转成 Chat Completions,也不翻译模型 ID。DeepSeek 官方本身就提供 Responses 兼容的接口。
菜单里多出两项:
| 菜单名称 | 请求中的模型 ID |
|---|---|
| DeepSeek Flash(官方 API) | deepseek-flash |
| DeepSeek Pro(官方 API) | deepseek-v4-pro |
配置层:只动两处
我的 Codex 已经有一个自定义的订阅 Provider,叫 crs,指向官方端点(来历见上一篇)。接入桥接时,config.toml 只改两个地方:
# 顶层:必须在第一个 [表] 之前
model_catalog_json = "~/.codex/deepseek-bridge/catalog.json"
# 原 Provider:其他字段不动,只换 base_url
[model_providers.crs]
base_url = "http://127.0.0.1:19427/<随机能力值>/backend-api/codex"(model_catalog_json 实际要写绝对路径。)
catalog.json 怎么来的:
- GPT 部分:直接用本机
~/.codex/models_cache.json里的条目,原样保留。每个人能用哪些 GPT 模型,由自己的账户和 App 版本决定,不能拿别人的模型目录替代。 - DeepSeek 部分:模型元数据取自 DeepSeek 官方的 Codex 安装脚本。我只下载下来读里面的模型 JSON,没有执行这个远程脚本,只改了显示名称。
如果你用的是内置的 openai Provider,那它不能被覆盖,得另外新建一个 Provider 表,不要写 [model_providers.openai]。
桥接的核心逻辑
桥接是一个只用 Python 标准库的 http.server,路由部分很短:
model = payload.get('model')
vendor = model in settings['deepseek_models']
base = settings['deepseek']['base_url'] if vendor else settings['native_base_url']
if vendor:
# DeepSeek:自己构造认证头,不携带任何 ChatGPT 认证信息
headers = {'Authorization': 'Bearer ' + read_deepseek_key(),
'Content-Type': 'application/json',
'X-Request-ID': correlation}
else:
# GPT:去掉 hop-by-hop 头,其余原样透传,包括订阅的认证头
headers = {k: v for k, v in self.headers.items() if k.lower() not in HOP_HEADERS}
headers['X-Request-ID'] = correlation响应用 read1() 按块读,再以 chunked 编码写回去,这样 SSE 流式输出能实时到达 App。
几个我认为重要的设计取舍:
| 设计 | 原因 |
|---|---|
只监听 127.0.0.1 | 启动时用断言检查,防止被误配成对局域网开放 |
| URL 路径带随机能力值 | 没有这个值的本机程序调不到模型端点,不能白用我的 Key。/health 不需要这个值 |
| 禁止跟随重定向 | 带认证头的请求一旦被重定向,凭据可能被带到别的地址 |
| 不重试、不跨厂商降级 | DeepSeek 报错就是报错,不会偷偷改用 GPT,避免账单和行为出现意外 |
| 关掉默认访问日志 | 请求 URL 里有能力值,不能写进日志 |
注意,能力值只能挡住随手调用的本机程序,挡不住同一用户权限下的恶意软件。它不是安全隔离。
Key 放在钥匙串,不落盘
DeepSeek Key 存在 macOS 钥匙串里。桥接通过 ctypes 调 Security.framework 读取,运行时只认钥匙串,不会退回去读环境变量。
导入时用隐藏输入,Key 不会出现在命令参数、shell 历史或任何文件里:
/usr/bin/python3 - <<'PY'
import getpass, os, sys
from pathlib import Path
sys.path.insert(0, str(Path.home() / '.codex/deepseek-bridge'))
import bridge
os.environ['DEEPSEEK_API_KEY'] = getpass.getpass('DeepSeek API Key(隐藏输入):')
sys.argv = ['bridge.py', 'import-key']
try:
bridge.main()
finally:
os.environ.pop('DEEPSEEK_API_KEY', None)
PY导入函数写完会马上读回来比对。如果已经存在同名条目,直接报错,不会覆盖。
这样做还有个好处:从 Dock 启动的 App 拿不到终端里的环境变量,但钥匙串里的 Key 照样能用。
用 launchd 常驻
桥接用 LaunchAgent 跑在当前用户下,配置 KeepAlive:
launchctl bootstrap "gui/$(id -u)" \
"$HOME/Library/LaunchAgents/local.codex-deepseek-bridge.plist"
curl --noproxy '*' --fail --silent http://127.0.0.1:19427/health
# {"service":"codex-deepseek-bridge","status":"ready"}用 $(id -u),不要写死 501,也不要加 sudo。
/health 正常只说明桥接进程在响应,不说明上游认证和模型调用没问题。
坑:系统代理吃掉了本地请求
第一次接通后,App 发请求直接得到 503,桥接日志里却一条请求都没有。
原因是 macOS 开着系统代理。Codex 的 HTTP 客户端没有遵守系统代理里的「本地地址例外」,把发往 127.0.0.1 的请求也交给了代理。
我的处理:桥接启动时,在 launchd 的 NO_PROXY 原有值后面追加 127.0.0.1,localhost。这个设置只对之后启动的 GUI 进程生效,所以要退出 App 再重新打开。原来的值提前备份,回退时恢复。
这是个兼容补丁。等客户端能正确处理回环地址例外,就应该把它去掉。
怎么确认请求真的去了该去的地方
不要靠问模型「你是谁」来判断,模型的自我介绍不可靠。我看的是桥接的结构化日志 requests.jsonl,每个请求按 correlation_id 对应一条 request 和一条 response:
{"event":"request","correlation_id":"…","provider":"deepseek","model":"deepseek-flash","upstream_host":"api.deepseek.com","input_bytes":1234}
{"event":"response","correlation_id":"…","provider":"deepseek","status":200,"output_bytes":5678,"duration_ms":2100}日志只记录模型、目标主机、状态码、字节数和耗时,不记录提示词、认证头或完整 URL。
另外写了一个付费的端到端验证脚本。它用临时 CODEX_HOME 和登录副本(refresh token 清空,防止刷新动到原来的登录),跑六项断言:
- GPT 返回指定标记
- Flash 返回指定标记,并记住一个合成口令
- 在同一会话里切到 Pro,能说出那个口令(验证跨模型的上下文延续)
- Flash 调 shell 执行
printf,退出码为 0 - Pro 做同样的 shell 检查
- 再开一个 GPT 会话,仍然正常
六项都通过。离线部分另外有 15 个单元测试(传输和认证边界 7 个,安装和回退 8 个),用临时 HTTP 服务和假认证跑,不调用真实模型。
可用性边界
GPT 也要经过桥接。 DeepSeek 出问题不会影响 GPT 的认证;但如果桥接进程挂了或卡住,GPT 也用不了。KeepAlive 只负责进程退出后自动拉起,处理不了进程卡死,也没有自动切回官方 Provider 的能力。
所以我准备了一个不依赖任何东西的回退脚本:zsh 加系统工具,不需要 Python、桥接、网络或 Codex。步骤是:
- ⌘Q 完全退出 App。
- 运行回退脚本,输入
RESTORE确认。 - 脚本校验备份,然后恢复接入前的
config.toml和models_cache.json,卸载 LaunchAgent,还原NO_PROXY。 - 重新打开 App,用 GPT 确认可用。
回退不动 auth.json,也不碰会话历史。登录文件的备份只在它确实损坏时手动恢复,不要因为桥接报错就去覆盖正常的登录。
排障速查
| 现象 | 先查 |
|---|---|
| 菜单里没有 DeepSeek | App 是否完全退出后重开;model_catalog_json 是否误放进了某个表里;路径是否存在 |
报错,但桥接日志没有 request | 桥接健康状态、端口、GUI 进程的 NO_PROXY、是否还是旧的 App 进程 |
| DeepSeek 返回 401/403 | Key、钥匙串授权、账户权限。不要去动 GPT 登录 |
| DeepSeek 返回 429/5xx | 上游状态和账户限额。桥接不会改用 GPT,也不会重发 |
| GPT 认证错误 | 订阅登录是否仍有效,必要时先回退再重新登录 |
| 进程反复退出 | service.stderr.log、解释器路径、端口冲突 |
总结
这个方案的核心就是三件事:
- 一份合并的模型目录,让菜单里出现 DeepSeek。
- 一个只看
model字段分流的本地桥接。 - 一个不依赖桥接也能运行的回退脚本。
GPT 的请求原样透传,DeepSeek 用自己的 Key,两边的凭据不会混在一起。代价是 GPT 多了一个本地单点,所以回退方案一定要在接入之前准备好。
以上只在 macOS 和上面提到的 App 版本上验证过。Windows/Linux、其他 App 版本、图片输入、联网搜索、长会话压缩这些情况都没测过,不保证兼容。
参考: