# 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..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 = 该构建不支持,需要换端点): ```bash curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \ -X POST "/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`,直接看那一行。