opencodex 代理 Codex 流式错误根因分析:从ApiError::Stream触发器到 RC1–RC5 修复全景
【免费下载链接】opencodexUniversal provider proxy for OpenAI Codex & Claude Code — use any LLM (Claude, Gemini, Grok, DeepSeek, Ollama…) with Codex CLI, App, SDK, and Claude Code项目地址: https://gitcode.com/gh_mirrors/ope/opencodex
导读
本文以 opencodex 项目中110_codex-stream-stability阶段的根因分析文档(devlog/_fin/110_codex-stream-stability/10_root-cause-analysis.md)为骨架,完整剖析通过 opencodex 代理驱动 Codex CLI 时频繁出现的 stream error 从何而来:它并非 SSE/WebSocket 传输层问题,而是 SSE 生命周期与可靠性缺陷。读者将掌握 Codex 消费端严格解析器的全部失败触发条件、opencodex 两条响应路径(passthrough 与 bridge)各自的故障模式,以及当前源码中 RC1–RC5 五个根因的修复落地方式,从而能对类似代理场景的流式中断问题做系统性排查与定位。
背景:一个被误诊为"传输问题"的流式故障
用户报告的现象是:ocx运行期间,通过代理驱动 Codex CLI 会产生大量 stream error。最初的怀疑方向包括"SSE 或 WebSocket 传输问题""是否应引入 SSE 多路复用/WS 提升性能""chat/completions 适配器上架 WS 是否有意义",以及"Codex passthrough 是否其实没有真正发生"。
该阶段的分析结论(见 00_overview.md)明确否定了传输层假设:这是一个 SSE 生命周期 / 可靠性问题,而不是协议传输问题。WebSocket 与 SSE 多路复用无法触达任何根因;"phase 100 不做 WebSocket"的决策维持不变(详见 20_transport-evaluation.md)。
关键前提是理解 opencodex 存在两条响应路径,且两条路径上的错误成因完全不同:
| 维度 | Passthrough(直通) | Bridge(翻译桥接) |
|---|---|---|
| 适配器 | openai-responses、azure(passthrough: true) | openai-chat、anthropic、google等 |
| 触发条件 | 默认openaiprovider +authMode: "forward" | routed 的provider/model命名空间 |
| 实现位置 | src/server/relay.ts中继upstreamResponse.body并调用sanitizePassthroughHeaders | bridgeToResponsesSSE()将适配器事件流重新编码为 Responses SSE |
| 保真度 | 高——上游事件原样中继 | 有损——只重发固定事件集 |
response.completed来源 | ChatGPT 后端(逐字原样) | 桥接层在done事件上合成 |
"passthrough 是否真的发生"的答案是:原生gpt-*模型默认走 passthrough;而 routed 模型(如opencode-go/deepseek-v4-pro这类 chat/completions 上游)在结构上不可能passthrough——上游不是 Responses 原生端点,opencodex 必须桥接。因此修复方向是桥接保真度,而非"强行 passthrough"。
Codex 消费端的流式错误模型:全部失败触发条件
Codex CLI 使用一个严格的 Rust 解析器消费代理的 SSE。该解析器是 vendored 的上游 codex-rs 代码(分析期位于/tmp/opencodex-codex-src/codex-rs/codex-api/src/sse/responses.rs,不在当前仓库内)。process_sse的轮询循环定义了流可能失败的每一种方式,且每一种都会变成ApiError::Stream(...):
let response = timeout(idle_timeout, stream.next()).await; // :446 match response { Ok(Some(Ok(sse))) => sse, // 正常事件 Ok(Some(Err(e))) => { send Err(ApiError::Stream(e)); return; } // :454 帧解码失败 Ok(None) => { send Err(response_error // :457-460 流提前结束 .unwrap_or(ApiError::Stream( "stream closed before response.completed"))); return; } Err(_) => { send Err(ApiError::Stream( // :464-468 空闲超时 "idle timeout waiting for SSE")); return; } }逐事件处理(responses.rs:347-410)再补充三类触发器,完整集合如下:
| 触发条件 | 位置 | 条件 |
|---|---|---|
response.failed且无可用error | :349、:378 | error缺失或无法反序列化为Error |
response.incomplete | :391 | 收到任意response.incomplete事件 |
response.completed解析失败 | :406 | ResponseCompleted反序列化失败 |
| 流在 completed 之前关闭 | :459 | 字节流结束且此前未捕获错误 |
| 空闲超时 | :466 | 在idle_timeout内没有收到任何 SSE |
| SSE 帧解码错误 | :454 | 线上出现畸形帧 |
这份触发器表是理解全部根因的地图——RC1–RC5 每一个都对应表中某一行的触发。分析中还澄清了三个约束代理行为的关键事实:
- 终止成功事件是
response.completed(:393)。chat/completions 惯用的data: [DONE]哨兵会被该解析器忽略——它只认response.completed。这意味着桥接层只发[DONE]而不发终态事件时,Codex 必然报"stream closed before response.completed"。 response.failed只读response.error,从不读last_error(:350)。error缺失或不可解析 →ApiError::Stream("response.failed event received")。ResponseCompleted只强制要求id: String,usage与end_turn均为#[serde(default)] Option<…>。桥接层始终设置id,因此 completed 载荷可以正常解析。
另外一个重要纠偏:解析器认可的error.code集合(responses.rs:557-580)包含context_length_exceeded、insufficient_quota、usage_not_included、invalid_prompt、cyber_policy、server_is_overloaded、slow_down;rate_limit_exceeded不在此集合内,它会落入通用的ApiError::Retryable { delay }分支而非专门的限流错误。这一行为可接受,但"与解析器代码检查匹配"的说法对rate_limit_exceeded并不成立——这是对原始假设的一处修正。
RC1:桥接层在无终止response.completed时结束流(Bridge 路径)
严重度:高。影响路径:bridge/routed。
旧版bridgeToResponsesSSE只在两个 switch 分支内发出终止事件:
case "done": emit("response.completed", …) case "error": emit("response.failed", …) // catch (err): emit("response.failed", …) … emitDone(); // → "data: [DONE]\n\n" (被 Codex 忽略) controller.close(); // → 字节流结束如果适配器生成器返回时没有 yield 出done或error,for await循环只是自然结束,控制流落到emitDone()+close()——没有任何response.completed发出。Codex 随后命中Ok(None)分支,报"stream closed before response.completed"(:459)。
这在分析期是真实可达的:anthropic.ts只在message_delta且携带usage的分支内发出done,message_stop是空操作,读取循环在 EOF 时 break 且循环后没有终止 yield。因此"结束于message_stop之后"或"message_delta未携带usage"的流都不会产生done→ RC1 触发。对照之下openai-chat.ts是安全的——它既处理[DONE],又在循环后有兜底的yield { type: "done" }。真正的缺陷是缺少一条不变量:桥接层必须保证发出一个终止的 Responses 事件,而不是某个具体适配器的个别问题。
当前源码的落地状态:这条不变量已在 src/bridge/sse.ts 中显式实现。当适配器生成器返回而terminated仍为 false 时,桥接层不再以静默 EOF 收尾,而是合成一个response.incomplete,incomplete_details.reason: "adapter_eof"(src/bridge/sse.ts#L1340-L1363)。选择incomplete而非completed是刻意的:生成器在无终止事件时返回意味着流被截断,把它报成干净完成正是这条路径要避免的失败模式。同时各适配器也已补齐终止 yield——例如anthropic.ts的message_stop分支现在通过emitDone()发出done(src/adapters/anthropic.ts),并在运输层 EOF 时 fail-closed:若流在message_stop前结束且stop_reason为error则报error事件,否则报error: "upstream stream ended before message_stop — possible truncation"(src/adapters/anthropic.ts#L1306-L1359)。
RC2:断连不中止上游;桥接层在已关闭的 controller 上二次抛错(两条路径)
严重度:高(交互场景)。影响路径:passthrough 与 bridge 均有。
上游fetch未传任何signal:
upstreamResponse = await fetch(request.url, { method, headers, body }); // bridge 路径 upstreamResponse = await fetch(request.url, { … }); // passthrough 路径桥接层ReadableStream只定义start(controller),没有cancel(reason)。当 Codex 客户端断开(打断、新一轮、工具循环、超时——交互场景中非常频繁)时:
- 上游 socket 永不中止 → 连接泄漏、浪费上游 token 与时间;
- 下一次
controller.enqueue()在已关闭的流上抛错,该错误被 catch 后又调用emit("response.failed")→enqueue再次抛错且未被捕获→ unhandled rejection;emitDone()/close()同样抛错。
在长时间交互会话中,这是"错误如潮水般爆发"("엄청 발생")最可能的驱动因素:每次取消都泄漏一条上游流,并在代理侧制造噪声错误。passthrough 路径的泄漏相同(同样无signal),但那里直接返回upstreamResponse.body,没有自定义cancel可加——修复点就是signal。
当前源码的落地状态:cancel()回调现在完整接管断连语义(src/bridge/sse.ts):置位clientCancelled与closed、清理 watchdog 与心跳定时器、调用cancelUpstreamOnce()中止上游、释放暂存数据并销毁翻译预算。而emit内的catch只在发现翻译预算超限(isTranslatorBudgetExceededError)时走专门终止路径,其余错误一律置closed = true后静默退出,配合if (closed) return的护栏,彻底消除了 RC2 描述的"已关闭 controller 上二次抛错"。
RC3:无空闲心跳;慢速 routed provider 触发空闲超时(Bridge 路径)
严重度:中(依赖 provider)。影响路径:bridge/routed。
Codex 在idle_timeout内未收到任何事件即报"idle timeout waiting for SSE"(responses.rs:446,464-468)。桥接层发出response.created覆盖了首 token 延迟,但流中途停顿期间不发出任何东西——慢速 routed provider、上游长时间思考间隙、慢速工具往返都会在 opencodex→Codex 一跳上制造静默。原生 passthrough 继承 ChatGPT 后端自身的 pacing/keep-alive,因此该问题主要咬合 routed 模型——而这恰好是代理最常用的配置(如opencode-go/deepseek-v4-pro)。
当前源码的落地状态:桥接层实现了基于heartbeatMs(默认 2000ms)的周期性心跳(src/bridge/sse.ts)。关键设计点是心跳的形态:codex-rs 的解析在事件级别做timeout(idle_timeout, stream.next()),因此一条 SSE 注释行不会派发事件、不会重置空闲计时器;默认心跳必须是解析器通过 catch-all 忽略的类型化帧event: response.heartbeat。而 grok 表面使用严格解码的 async-openai fork,遇到未知的response.heartbeat变体会崩溃,但它的事件源是字节级的,空闲处理容忍注释行——因此 grok 表面通过options.heartbeatStyle: "comment"选用: opencodex heartbeat注释帧(src/bridge/sse.ts#L327-L329,#L120处注释有完整说明)。心跳逻辑还区分"上游活动"与"线上活动":上游适配器的心跳与缓冲进度只重置 stall 看门狗(stallTicks),只有线上真正静默才发射心跳帧。若 stall 超过resolveStallTimeoutSec换算出的最大 tick 数,则终止流并发出response.incomplete,incomplete_details.reason: "upstream_stall_timeout"(src/bridge/sse.ts#L1389-L1419)。
RC4:桥接保真度——错误信封与丢帧(Bridge 路径)
严重度:中。影响路径:bridge/routed。部分由 phase 100.5 修复。
错误信封(已修复):100.5 之前桥接层只带last_error发出response.failed。Codex 只读error(:350),于是每个翻译后的失败都变成笼统的ApiError::Stream("response.failed event received")。Phase 100.5(commita0d4ec9)通过classifyError(见 src/lib/errors.ts)加入分类后的error,context_length_exceeded与insufficient_quota现在与解析器的is_*_error检查精确匹配。遗留注意点:classifyError对 429 统一产出rate_limit_exceeded(src/lib/errors.ts#L334、#L364),而解析器不特判该 code → 落入通用ApiError::Retryable;同时桥接层同时发出error与last_error,后者被解析器忽略,属于无害冗余(当前 src/bridge/sse.ts 仍保持双字段输出)。
静默丢帧:适配器对 JSON 解析失败统一catch { continue },畸形的或跨块切分的上游帧被静默丢弃。这在 Codex 侧不抛错(它忽略不可解析帧,:476-478),但会截断内容,并与 RC1 叠加导致无终止事件地结束流。畸形代理输出:若 opencodex 发出畸形的 Responses 帧,Codex 以ApiError::Stream(:454)呈现——当前未观察到,但这是保持sseEvent(src/bridge/sse.ts)严格良构的原因。
当前源码的落地状态:错误路径已统一为"先清理打开项(failCurrentToolCall、closeCurrentWebSearch("failed"))→classifyError生成error/last_error→response.failed→reportTerminal("failed")"的完整序列(src/bridge/sse.ts#L1263-L1294),且isCyberPolicyCode时会附加retryable: false。工具参数不可解析(toolCallArgumentsUsable失败)也会走 fail-closed:取消该工具项并以upstream_error终止整轮(src/bridge/sse.ts#L1098-L1119)。
RC5:Passthrough 头部保真度(Passthrough 路径)
严重度:中。影响路径:原生gpt-*。已由 phase 100.5 缓解,需验证。
passthrough 路径通过sanitizePassthroughHeaders中继upstreamResponse.body。Bun 的fetch会自动解压 body,但会留下上游的content-encoding: gzip与过期的content-length。若这些头被原样中继,Codex 客户端会二次解码/截断 → 畸形帧 →ApiError::Stream(:454)。
当前源码的落地状态:丢弃集合在 src/server/relay.ts 中实现,覆盖content-encoding、content-length、transfer-encoding、connection、keep-alive、proxy-authenticate、proxy-authorization、set-cookie、set-cookie2、te、trailer、upgrade。分析文档列出的待验证项至今仍有意义:确认content-type: text/event-stream能穿过清洗,并确认 Bun 始终自动解压 passthrough body(如果某天它中继原始 gzip 字节,丢弃content-encoding本身反而会破坏流)。
可能性与影响:映射到真实使用场景
代理最常见的指向是routed 模型(chat/completions 上游),这把用户置于bridge 路径,RC1 + RC3(+ 断连时的 RC2)在此叠加:
- RC1(缺终止事件)与 RC2(断连二次抛错)——交互式 Codex 会话中出现频率最高,直接产生
ApiError::Stream; - RC3(空闲超时)——频率随上游延迟/停顿放大;
- RC4 / RC5——信封正确性(基本已修)与头部卫生(基本已修),残余风险是静默截断与
rate_limit_exceeded分类缺口。
分析给出的最高杠杆不变量是:代理必须总是以恰好一个response.completed或分类后的response.failed终止流式响应,并且客户端离开时必须中止上游。对照当前源码,这条不变量已在 src/bridge/sse.ts 中全面落地——终态要么来自done/error/incomplete分支,要么由 EOF 兜底合成adapter_eof,要么由 stall 看门狗触发upstream_stall_timeout,配合reportTerminal的幂等护栏与cancel()的断连中止,RC1–RC3 的原始故障路径均已闭合;RC4/RC5 的残余项则作为持续验证清单保留。后续实现细节可继续阅读 30_patch-direction.md 与 51_success-stream-error-envelope.md 等闭环节点文档。
【免费下载链接】opencodexUniversal provider proxy for OpenAI Codex & Claude Code — use any LLM (Claude, Gemini, Grok, DeepSeek, Ollama…) with Codex CLI, App, SDK, and Claude Code项目地址: https://gitcode.com/gh_mirrors/ope/opencodex
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考