这次折腾 Codex Desktop,起因很简单:我想让它走第三方 OpenAI 兼容接口,比如 Fucheers / NewAPI,同时保留桌面端左侧那些旧聊天和 Projects。

听起来只是改个 API 地址,但实际一路踩了不少坑。最开始我以为是认证问题,后来怀疑是数据库问题,最后才发现,真正影响左侧聊天目录的,很可能是 Codex 内部的 provider 命名空间。

这篇文章记录整个排查过程,也整理一下最后能用的配置,以及后续怎么继续验证这些猜想。

1. 问题是怎么出现的

我最开始把 config.toml 改成了自定义 provider:

model_provider = "custom"

[model_providers.custom]
name = "openai"
base_url = "https://www.fucheers.top/v1"
wire_api = "responses"

这个配置的确能让 Codex 使用第三方接口。但很快出现了一个严重副作用:

左侧历史聊天几乎全没了,只剩几条新聊天。

更奇怪的是,项目有时还在,有时也会不见。于是第一反应很自然:是不是聊天记录没了?是不是数据库坏了?是不是账号换了?

后来反复验证后发现,都不是。

2. 聊天其实没有丢

整个过程中,我没有删除数据库,也没有清空任何聊天记录。主要改动只有两个文件:

C:\Users\XKH\.codex\config.toml
C:\Users\XKH\.codex\auth.json

但只要切换配置,左侧聊天列表就会变化。

这说明聊天记录大概率还在本地,只是 Codex Desktop 当前没有把它们展示出来。

这个判断很关键。因为如果一开始误以为“数据丢了”,就很容易去做迁移数据库、清缓存、删配置之类的高风险操作。实际这次更像是:数据还在,但被当前配置过滤掉了。

3. Projects 和 Chats 不是一回事

中途还有一个现象容易误导人:Projects 和 Chats 的表现不完全一致。

有一次配置里缺了 [projects] 部分,结果 Projects 消失了。恢复 [projects] 后,Projects 回来了,但旧聊天仍然没回来。

这说明 Codex Desktop 里至少有两套索引逻辑:

Projects 一套
Chats 一套

Projects 是否显示,和 config.toml 里的 [projects] 强相关。

但聊天是否显示,还受别的字段影响。

4. 真正关键的是 model_provider

最后反复对比后,最关键的规律出现了:

model_provider = "openai"

旧聊天回来。

model_provider = "custom"

旧聊天消失,只显示 custom 期间创建的少量新聊天。

这基本说明,左侧聊天列表很可能和当前 model_provider 绑定。

也就是说,Codex Desktop 不是简单地把本地所有聊天都列出来,而是会按当前 provider 或运行命名空间筛选。

旧聊天大概率属于:

provider = openai

而我切到:

provider = custom

之后,Desktop 就只展示 custom 这个命名空间下的聊天。

5. “是不是被当成两个用户了?”

这也是我后来想到的一个猜想。

因为现象看起来确实很像:切到 custom 后,旧聊天不见了;用 custom 创建的新聊天,又像是另一套聊天历史。于是很容易怀疑:

是不是 Codex 把它们当成了两个不同用户的数据,所以不互通?

现在看,这个猜想有一定道理,但表述上可以更精确一点。

它不像是两个 Windows 用户,也不像是两个真正的账号。更可能是:

同一个本地用户
同一个 ~/.codex
同一套本地数据库

但分成了不同 provider / runtime namespace

也就是说,openaicustom 不是两个用户,但在聊天列表筛选上,它们像两套不同的会话空间。

可以把它理解成:

provider = openai  -> 显示旧聊天
provider = custom  -> 显示 custom 期间创建的新聊天

所以“被当成两个用户”这个直觉是有价值的,只是底层原因更可能不是 user id,而是 provider id、runtime id、host id、account id 等字段组合出来的会话作用域。

目前最强的证据仍然是:

只改 model_provider,聊天列表就变化。

而只改 auth.json,更多影响的是认证和 401,不是聊天是否显示。

6. name = "openai" 没有用

我还试过一种看起来很合理的写法:

model_provider = "custom"

[model_providers.custom]
name = "openai"
base_url = "https://www.fucheers.top/v1"
wire_api = "responses"

直觉上会想:我把名字写成 openai,是不是 Codex 就会按 openai 处理?

结果并不会。

Codex 看的是 section 名,也就是这里的:

[model_providers.custom]

而不是:

name = "openai"

更进一步,如果尝试直接写:

[model_providers.openai]

Codex 会报错,因为 openai 是内置保留 provider,不能覆盖。

错误大意是:

model_providers contains reserved built-in provider IDs: openai
Built-in providers cannot be overridden.

这一步基本确认了:想保留旧聊天,就不能把 provider 切到 custom。

7. 正确方向:保留 openai,只改 base URL

后来查到官方手册里的推荐写法:

openai_base_url = "https://us.api.openai.com/v1"

也就是说,如果只是想让内置 OpenAI provider 指向代理、路由器或第三方兼容接口,不应该重新定义 provider,而应该这样写:

model_provider = "openai"
openai_base_url = "https://www.fucheers.top/v1"

这个配置的意义很重要:

provider 仍然是 openai
所以旧聊天还能显示

实际 API base URL 改成第三方接口
所以请求可以走 Fucheers / NewAPI

这一步解决了最核心的“左侧旧聊天消失”问题。

8. 中途踩过的几个坑

第一个坑是 requires_openai_auth = true

这个字段看起来像是“我要用 OpenAI 认证”,但对第三方兼容接口来说,反而可能导致 401。因为它可能触发 Codex 使用官方 OpenAI 的认证方式,而第三方接口不一定接受。

所以最后配置里不保留它。

第二个坑是 disable_response_storage = true

这个字段不适合当前目标。我的目标是保留 Desktop 历史和完整本地体验,不应该随便关闭 response storage。

第三个坑是 TOML 语法。

有一次 config.toml 某个 table header 写坏了,Codex 直接提示:

unclosed table, expected `]`

然后当前对话也无法继续,界面一直处在加载状态。

所以每次改完配置,都应该先用 TOML parser 验证:

@'
from pathlib import Path
import tomllib

p = Path(r'C:\Users\XKH\.codex\config.toml')
data = tomllib.loads(p.read_text(encoding='utf-8-sig'))

print("TOML_OK")
print("model_provider =", data.get("model_provider"))
print("openai_base_url =", data.get("openai_base_url"))
print("project_count =", len(data.get("projects", {})))
print("features =", data.get("features"))
'@ | python -

9. 最后一个问题:WebSocket 404

当旧聊天终于恢复后,又出现了另一个问题:

unexpected status 404 Not Found: Invalid URL (GET /v1/responses),
url: wss://www.fucheers.top/v1/responses

这个错误说明 Codex 在某些场景下试图走 Responses WebSocket:

wss://www.fucheers.top/v1/responses

但 Fucheers / NewAPI 这类兼容接口通常支持的是 HTTP/SSE Responses,不一定支持 Codex 所需的 WebSocket Responses endpoint。

这就导致了 404。

后来在本地 Codex 二进制里能看到两个 feature flag:

responses_websockets
responses_websockets_v2

于是最后加了一个很小、可逆的配置:

[features]
responses_websockets = false
responses_websockets_v2 = false

这样做的目的不是换 provider,而是在保留 built-in openai provider 的前提下,尽量让 Codex 不再走 WebSocket,而是回落到 HTTP/SSE。

10. 最终可用配置

最终核心配置大概是这样:

model_provider = "openai"
openai_base_url = "https://www.fucheers.top/v1"
model = "gpt-5.5"
model_reasoning_effort = "high"
persistence = "save-all"
personality = "pragmatic"
service_tier = "default"

[features]
responses_websockets = false
responses_websockets_v2 = false

验证结果应该类似:

TOML_OK
model_provider = openai
openai_base_url = https://www.fucheers.top/v1
features = {
  responses_websockets = false,
  responses_websockets_v2 = false
}
project_count = 8

改完后一定要完全退出并重启 Codex Desktop。只刷新窗口不一定够,因为 app-server 可能仍在使用旧配置。

11. 后续怎么验证这个猜想

目前最核心的猜想是:

Codex Desktop 的聊天列表不是简单展示所有本地聊天,
而是按 provider / runtime / account / host 等命名空间过滤。

其中 model_provider 至少是一个非常重要的筛选因素。

要进一步验证,可以做几组更有控制的实验。

实验一:在 custom 下创建一条带标记的新聊天

先切到:

model_provider = "custom"

然后新建一条聊天,标题或第一句话写得非常明显,例如:

CUSTOM_PROVIDER_TEST_2026_06_28

确认它在左侧列表里可见。

然后切回:

model_provider = "openai"

完全重启 Codex Desktop,再搜索或观察左侧列表。

如果这条 CUSTOM_PROVIDER_TEST_2026_06_28 消失,而旧 openai 聊天回来,就说明 custom 聊天确实被放进了另一套 namespace。

实验二:再切回 custom,看那条测试聊天是否回来

接着再次切回:

model_provider = "custom"

完全重启 Codex Desktop。

如果 CUSTOM_PROVIDER_TEST_2026_06_28 又出现,而旧 openai 聊天又消失,就能进一步证明:

聊天没有丢,只是当前 provider 决定了显示哪一组。

这会非常支持“provider namespace”猜想。

实验三:只改 auth.json,不改 model_provider

保持:

model_provider = "openai"

然后只替换或调整 auth.json,观察旧聊天是否还在。

如果旧聊天仍然显示,只是请求报 401 或认证失败,就说明:

auth.json 主要影响认证,不是聊天列表分组的核心条件。

这和之前的观察一致。

实验四:只改 name 字段,不改 section 名

测试:

model_provider = "custom"

[model_providers.custom]
name = "openai"
base_url = "https://www.fucheers.top/v1"
wire_api = "responses"

如果旧聊天仍然不回来,就说明 Codex 判断 provider 的依据不是 name,而是 section id,也就是:

custom

这也解释了为什么 name = "openai" 没有效果。

实验五:比较日志里的 provider 和 transport

可以查看 Codex 日志,重点找这些字段:

model_provider
provider
wire_api
transport
responses_http
responses_websocket
api.path="responses"

如果在 openai_base_url 方案下看到:

transport="responses_http"
POST https://www.fucheers.top/v1/responses

就说明请求已经通过 HTTP/SSE 走第三方接口。

如果看到:

wss://www.fucheers.top/v1/responses

说明仍然触发了 WebSocket,需要检查:

[features]
responses_websockets = false
responses_websockets_v2 = false

是否生效,并确认是否完全重启了 Codex Desktop。

实验六:检查本地数据库字段,但不要直接改

如果想进一步确认底层字段,可以只读方式查看本地 SQLite 表结构。

重点找类似字段:

provider
provider_id
model_provider
runtime
runtime_id
account_id
host_id
thread_id
conversation_id

注意,这一步只建议只读检查,不建议直接修改数据库。因为当前已经有不改数据库的解决方案,直接改 DB 风险太高。

如果数据库里确实能看到聊天和 provider/runtime/account 之类字段关联,就能进一步证明左侧列表为什么会分组显示。

12. 最后的理解

这次问题最后可以总结成两句话。

第一句:

Codex Desktop 的左侧聊天列表,很可能按 provider/runtime namespace 过滤。

所以切到 custom 后,旧的 openai 聊天不会显示。不是聊天丢了,也不是数据库坏了。

第二句:

第三方 OpenAI 兼容接口应该优先用 openai_base_url 接入,而不是自定义 openai provider。

这样才能保留 openai 这个 provider 命名空间,从而保留旧聊天。

如果第三方接口不支持 Codex 的 Responses WebSocket,再补上:

[features]
responses_websockets = false
responses_websockets_v2 = false

13. 给后来人的排查顺序

如果以后再遇到类似问题,我会按这个顺序查:

  1. 先备份 C:\Users\XKH\.codex\config.toml
  2. 确认没有改数据库、没有删聊天
  3. 用 TOML parser 验证 config.toml
  4. 确保 model_provider = "openai"
  5. openai_base_url 指向第三方接口
  6. 不要写 [model_providers.openai]
  7. 不要随便加 requires_openai_auth = true
  8. 如果出现 wss://.../responses 404,再关闭 Responses WebSocket
  9. 完全重启 Codex Desktop
  10. 观察旧聊天、Projects、发送消息是否都正常

这次最大的教训是:表面上像是聊天丢了,实际上更像是 Codex 换了一个会话视角。把视角切回 openai,旧聊天就还在那里。