这份文档是 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\(本目录多数文件由它移植)。
渲染进程 ──IPC(controller/codexCtl/<方法>)──> 主进程
│ spawn(codex.exe, ['app-server','--listen','stdio://', ...])
│ stdin ← 一行一个 JSON 请求
│ stdout → 一行一个 JSON 响应 / 通知 / 服务端请求
▼
codex app-server 子进程
│ HTTPS,Responses 协议(Codex 自己管,我们不参与)
▼
模型端点(模型管理里那条记录的 base_url)
ws:// 只是 codex app-server --listen 可选的服务端监听形态,我们不用。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 二进制 |
store.send(text);首次发送前 ensureModelApplied() → 主进程 applyProvider:
探端点(第 4 节)→ 重启子进程并注入 -c(api key 走子进程环境变量,不落盘)。thread/start(带 cwd/sandbox/approvalPolicy/model)拿 threadId,会话与线程 1:1。turn/start 立即返回 turn{id, status:"inProgress"}(实测 0.3 秒),不阻塞等生成。item/started、item/agentMessage/delta、item/reasoning/summaryTextDelta、
item/commandExecution/*、item/fileChange/patchUpdated、turn/plan/updated、
thread/tokenUsage/updated(我们把它显示成"上下文用量"一行)。item/commandExecution/requestApproval 等),
approvalBroker 转给页面,页面回 accept/decline/...;2 分钟无答复按拒绝收尾。turn/completed{status} 收尾;失败也一定会有这一条(status:"failed" + turn.error)。
所以"回合卡住"只会是端点在慢慢 prefill,不会是结局没送到。threadId 失效:turn/start 报 thread not found 时先 thread/resume
再发一次(这次 RPC 失败时上游还没开始生成,不会重复付 prefill);resume 也失败就提示新建会话。| 常量 | 值 | 位置 | 管什么 |
|---|---|---|---|
| 单条 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,不刷屏 |
我们不做任何重试。 一次回合 = 上游一次生成。这是硬规则:自部署端点多是单槽推理, 重试不是容错,是把同一份几分钟的活儿再排一遍队。
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 也恰等于默认值。写它们等于什么都没改。electron/service/codex/codexIdle.itest.ts,三条都过):
stream_idle_timeout_ms=5000 → 不判死(计时器只在流建立后武装);response.created 再静默 30 秒,同样 5 秒窗口 → 判死,文案
idle timeout waiting for SSE,且 stream_max_retries=0 时上游只被问 1 次;/v1/responsesCodex 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 允许应用。
用真实 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 分钟才有第一个字。
所以页面上是这两行,而不是进度条:
thread/tokenUsage/updated):本轮输入/输出/命中缓存 tokens 与上下文窗口大小,
同一回合内原地更新。想快只有两条路:换更快的端点,或者少带上下文(关掉的 MCP 工具、启用的 skill
会直接体现在 tools 那 18KB 里)。所有诊断走同一条出口:内存环形缓冲 + 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,出现这行说明该会话被显式改过参数 |
三条最常见的病:
stream_idle_timeout_ms。Codex 协议流已关闭 / 子进程退出码 → 十有八九是 -c 参数被 Codex 判非法(例如覆盖内置 provider id、
或 config 里塞了它不认的键)。退出信息已经拼上 stderr 原文并落 ee.log,直接看那一行。