CODEX.md 11 KB

Codex 桌面端集成(ai-electron)

这份文档是 Codex 链路的唯一权威说明:走什么协议、一次消息经历了什么、系统里一共有哪几个计时器、 以及端点必须满足什么条件。改这块代码前先读完第 3、4 节 —— 前六个提交之所以没收口, 就是因为按一份写错的超时说明在调参。

代码位置:主进程 ai-electron/electron/service/codex/,控制器 ai-electron/electron/controller/codexCtl.ts, 渲染进程 ai-electron/frontend/src/codex/。参照实现:E:\workspace\ai\Noobi.ai\src\main\(本目录多数文件由它移植)。


1. 通信方式:stdio 上的换行分隔 JSON-RPC

渲染进程 ──IPC(controller/codexCtl/<方法>)──> 主进程
                                              │  spawn(codex.exe, ['app-server','--listen','stdio://', ...])
                                              │  stdin  ← 一行一个 JSON 请求
                                              │  stdout → 一行一个 JSON 响应 / 通知 / 服务端请求
                                              ▼
                                        codex app-server 子进程
                                              │  HTTPS,Responses 协议(Codex 自己管,我们不参与)
                                              ▼
                                        模型端点(模型管理里那条记录的 base_url)
  • 只有这一条通道。没有 WebSocket,没有 HTTP 服务,没有轮询。 客户端 ↔ Codex 走 stdio JSONL;ws:// 只是 codex app-server --listen 可选的服务端监听形态,我们不用。
  • Codex 内部确实有一条 Responses-over-WebSocket 通道,那是它连模型端点时的一种传输选择, 由 model_providers.<id>.supports_websockets 决定。用 -c 声明的 provider 该字段是 serde 默认值 false, 所以我们这条路上永远不走 WS(见第 3 节,那句"15 秒"的误判就出在这里)。
  • 子进程只有一个,被所有会话共用;退出即置 error,不自动重启(下一次操作懒启动)。

与 Noobi.ai 的文件级对照(同名即同实现,超时/重试策略也照它):

我们 Noobi.ai 职责
jsonRpcPeer.ts src/main/jsonRpcPeer.ts JSONL 通道、30 秒单请求预算、16/48MiB 行上限
codexRuntime.ts src/main/codexAppServer.ts 子进程生命周期、RPC 封装、回合等待
eventMapper.ts src/main/eventMapper.ts 通知 → 统一的 AgentEvent
approvalBroker.ts src/main/approvalBroker.ts 服务端请求(审批)转发与回包
eventLog.ts src/main/eventLog.ts 事件 JSONL 落盘,供 UI 回放
codexLocator.ts src/main/codexLocator.ts 找 vendor 里的 codex 二进制

2. 一条消息的完整时序

  1. 渲染进程 store.send(text);首次发送前 ensureModelApplied() → 主进程 applyProvider: 探端点(第 4 节)→ 重启子进程并注入 -c(api key 走子进程环境变量,不落盘)。
  2. thread/start(带 cwd/sandbox/approvalPolicy/model)拿 threadId,会话与线程 1:1。
  3. turn/start 立即返回 turn{id, status:"inProgress"}(实测 0.3 秒),不阻塞等生成。
  4. 生成期间是通知流:item/started、item/agentMessage/delta、item/reasoning/summaryTextDelta、 item/commandExecution/*、item/fileChange/patchUpdated、turn/plan/updated、 thread/tokenUsage/updated(我们把它显示成"上下文用量"一行)。
  5. 需要授权时 Codex 反过来发服务端请求(item/commandExecution/requestApproval 等), approvalBroker 转给页面,页面回 accept/decline/...;2 分钟无答复按拒绝收尾。
  6. turn/completed{status} 收尾;失败也一定会有这一条(status:"failed" + turn.error)。 所以"回合卡住"只会是端点在慢慢 prefill,不会是结局没送到。
  7. 子进程重启/换模型后旧 threadId 失效:turn/start 报 thread not found 时先 thread/resume 再发一次(这次 RPC 失败时上游还没开始生成,不会重复付 prefill);resume 也失败就提示新建会话。

3. 超时与重试:一张表说了算

我们这一侧(ai-electron)

常量 值 位置 管什么
单条 RPC 预算 30 秒 jsonRpcPeer.ts request(..., timeoutMs = 30_000) 请求→响应。不包回合时长:turn/start 是异步的,秒回
回合宿主期限 20 分钟 codexRuntime.ts TURN_TIMEOUT_MS 到点发 turn/interrupt 并判失败(对齐 Noobi 同值)
端点探测 15 秒 providerService.ts PROBE_TIMEOUT_MS 只问路由在不在,绝不触发推理
审批等待 2 分钟 approvalBroker.ts APPROVAL_TIMEOUT_MS 无人应答按拒绝收尾
慢 RPC 诊断 3 秒 codexRuntime.ts SLOW_RPC_MS 超过才记一行 layer=rpc,不刷屏

我们不做任何重试。 一次回合 = 上游一次生成。这是硬规则:自部署端点多是单槽推理, 重试不是容错,是把同一份几分钟的活儿再排一遍队。

Codex 那一侧(源码 rust-v0.155.1,我们用 -c 改的就是这些)

参数 Codex 默认 我们注入 源码
stream_idle_timeout_ms 300_000 1_800_000 model-provider-info/src/lib.rs:29;计时器在 codex-api/src/sse/responses.rs:580-606
stream_max_retries 5 0 lib.rs:30;重试判定 core/src/responses_retry.rs:108、可重试集合 protocol/src/error.rs:372-413
request_max_retries 4 0 lib.rs:31
websocket_connect_timeout_ms 15_000 不写(走不到) lib.rs:34;只包 WS 握手 core/src/client.rs:1081-1099
supports_websockets serde 默认 false 不写 lib.rs:149-151

这段是以前那次误判的正面记录,别再走一遍:

  • 客户端日志里那句 request timed out + retrying sampling request (n/5) + Falling back from WebSockets to HTTPS transport 只可能出自 WebSocket 连接路径(15 秒那个)。而我们声明的 provider supports_websockets=false, 这条路一次都不会走 —— 所以"每 15 秒掐一次流"从来不存在。
  • 真正会咬人的是 stream_idle_timeout_ms:流建立之后,Codex 每次轮询都套 300 秒的表; 长时间没有新事件就抛可重试的 CodexErr::Stream("idle timeout waiting for SSE")。 单槽端点 prefill 几分钟到二十几分钟,正好撞在这把刀上。
  • 之前提交里"修复"的四项参数有两项是空操作:stream_idle_timeout_ms=300000 恰等于默认值、 supports_websockets=false 也恰等于默认值。写它们等于什么都没改。
  • 实测(真 codex.exe + 受控 mock,electron/service/codex/codexIdle.itest.ts,三条都过):
    1. 连响应头都不发、静默 30 秒,stream_idle_timeout_ms=5000 → 不判死(计时器只在流建立后武装);
    2. 先发响应头 + response.created 再静默 30 秒,同样 5 秒窗口 → 判死,文案 idle timeout waiting for SSE,且 stream_max_retries=0 时上游只被问 1 次;
    3. 同样的静默,窗口放宽到 120 秒 → 正常完成,仍是 1 次请求。 这条就是我们把默认窗口抬到 30 分钟的依据。

4. 端点必须会答 /v1/responses

Codex 0.155.1 起 wire_api = "chat" 被删掉,且加载 config.toml 时就报错,不是运行时降级:

Error loading config.toml: `wire_api = "chat"` is no longer supported.
How to fix: set `wire_api = "responses"` in your provider config.

实测 0.146.0 / 0.151.0 / 0.155.1 三个版本行为一致。曾经为此内置过一个 Responses↔Chat 桥接, 现在整个删掉:不做协议翻译,端点不会说 Responses 就拒绝应用模型,并把地址、状态码、自证命令一起报出来。

自证(4xx = 路由存在,可用;404/405 = 该构建不支持,需要换端点):

curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
  -X POST "<base_url>/responses" -H 'content-type: application/json' \
  -d '{"model":"<模型名>"}'

请求体故意不带 input:单槽服务一旦真开始生成就把整个 HTTP 服务占住(实测 max_tokens:1 也要 14 秒, 期间连 /models 都不应答),探测必须只问路由。代价是模型名写错要到真正提问时才暴露。

探测结果四态(providerService.probeEndpoint):responses / chat-only / unsupported / unreachable, 只有 responses 允许应用。

5. 一次回合有多慢是物理决定的

用真实 codex.exe 抓下来的请求体(空目录、只发一句 "hi"):

总 41,223 字节 = instructions 17,733 + tools 18,371(9 个工具)+ input 3,787
              + client_metadata 1,056 + prompt_cache_key 38 + 其余若干

这 ~4 万字节是 Codex 自带的 agent 开场(系统指令 + 工具定义 + 环境上下文), 不是我们拼的,删不掉。真实会话再往上叠历史,实测能到 4.6 万 token。 按自部署端点 ~29 token/s 的 prefill 算:46,000 / 29 ≈ 26 分钟才有第一个字。

所以页面上是这两行,而不是进度条:

  • 「回合开始」:Codex 已开始处理当前任务;端点要把完整上下文重新 prefill,首字之前不会有任何输出。
  • 「上下文用量」(来自 thread/tokenUsage/updated):本轮输入/输出/命中缓存 tokens 与上下文窗口大小, 同一回合内原地更新。想快只有两条路:换更快的端点,或者少带上下文(关掉的 MCP 工具、启用的 skill 会直接体现在 tools 那 18KB 里)。

6. 排障:诊断行怎么读

所有诊断走同一条出口:内存环形缓冲 + ee.log(logger.warn('[codex] …'))+ 推送 codex/diagnostic。 现在每行带层前缀:

前缀 出处 看到什么说明什么
layer=rpc <方法> 用了 N 秒 / 失败(N 秒):… codexRuntime.#request 主进程 ↔ app-server 这一段慢或断。30 秒预算打满 = 子进程没回话,通常是它自己挂了或 stdio 被堵
layer=turn turn=… status=… 用时 …s codexRuntime.runTurn 一整轮的真实耗时。status=failed 去翻同一 turnId 的 error 事件
layer=turn turn=… 宿主侧期限已到(1200 秒) 同上 是我们 20 分钟期限到点,不是端点故障
无 layer= 前缀、含 idle timeout waiting for SSE Codex 转上来的 error 事件 空闲窗口不够大:调 stream_idle_timeout_ms(模型管理 config 里显式写即可覆盖默认)
含 Reconnecting... n/m Codex 自己的重试播报 上游流断了。我们已把 stream_max_retries=0,出现这行说明该会话被显式改过参数

三条最常见的病:

  1. 发一条就报错、但只问了一次上游 → 空闲窗口太小,见第 3 节表格,改 stream_idle_timeout_ms。
  2. 报"端点没有 /responses" → 端点构建不支持 Responses,客户端不会替它翻译,升级端点。
  3. Codex 协议流已关闭 / 子进程退出码 → 十有八九是 -c 参数被 Codex 判非法(例如覆盖内置 provider id、 或 config 里塞了它不认的键)。退出信息已经拼上 stderr 原文并落 ee.log,直接看那一行。