【免费下载链接】opencodex
Universal provider proxy for OpenAI Codex & Claude Code — use any LLM (Claude, Gemini, Grok, DeepSeek, Ollama…) with Codex CLI, App, SDK, and Claude Code
导读
opencodex 是面向 OpenAI Codex CLI / App / SDK 与 Claude Code 的通用 Provider 代理(README.md)。本文聚焦260716_claudecode_hardening工作包 3(030_5xx_persistence.md)的核心改动:把请求失败诊断字段(errorCode/terminalStatus/closeReason/upstreamError)以additive(追加式)方式写入usage.jsonl持久化条目,解决"慢速 502 还是 SSE 中途response.failed无法判定"的事故复盘盲区。读完本文,你将理解这些字段的落盘语义、触发门槛、脱敏链路与验收断言方式,并能直接在自己的部署中通过usage.jsonl回溯任意一次上游失败。
背景:一次无法判定根因的事故
2026-07-16 的事故(即260716事件)暴露了一个根本性的可观测性缺陷:当一次请求以失败结束时,运维人员无法区分它到底是"上游缓慢返回的 502",还是"SSE 流中途收到了response.failed"。根因在于:
errorCode、terminalStatus、closeReason、upstreamError这四个诊断字段只存在于进程内存的 200 条环形缓冲区(requestLog,见 src/server/request-log.ts);- 它们没有随持久化的
usage.jsonl条目落盘,导致事故发生后内存缓冲被新请求冲掉,诊断线索永久丢失。
修复策略在 000_plan.md 中定义为三道防线:① 代理对 pre-stream 5xx 直接重试;② 重试耗尽后映射为 Claude Code 可自行重试的529 overloaded_error;③ 即本文——将失败条目以可判定形式持久化,为下一次事件提供依据。
设计原则:不新建文件,只在既有条目上做加法
该方案的三个关键约束(详见 030_5xx_persistence.md):
- 不新建日志文件:失败诊断直接搭载在既有
usage.jsonl条目上,避免引入新的文件生命周期、轮转策略与消费端改造。 - Additive 兼容:所有新增字段均为可选字段,旧版读取器遇到未知字段会自然忽略,因此向后兼容。
- 只写失败,不写成功:成功条目(2xx 且 terminal 为
completed)保持原样,把对usage.jsonl文件增长速率的影响降到最低。
实现拆解一:PersistedUsageEntry新增可选诊断字段
持久化条目的类型定义位于 src/usage/log.ts,新增的四个可选字段(约 L318-L329):
export interface PersistedUsageEntry { // ...既有字段... errorCode?: string; /** 封闭枚举,来自 request-outcome.ts;作为分组键落盘 */ terminalStatus?: RequestTerminalStatus; closeReason?: RequestCloseReason; /** 捕获时刻已完成 redactSecretString + slice(0,500) */ upstreamError?: string; }注意实际实现中terminalStatus与closeReason使用的是封闭联合类型RequestTerminalStatus/RequestCloseReason,而非文档初稿中的宽字符串字面量。这两个枚举定义在 src/usage/request-outcome.ts:
RequestTerminalStatus = "completed" | "failed" | "incomplete"(由结局分类派生,aborted排除在外,因为它是调用方主动离开而非上游终端);RequestCloseReason = "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow"。
为什么必须是封闭枚举?注释给出了清晰理由:terminalStatus一旦成为分组键(grouping-key slot),其值来自上游 terminal frame,若类型开放,上游可控文本就可能进入分组键(request-outcome.ts 中isRequestTerminalStatus/isRequestCloseReason两个读回守卫正是为此而设)。normalizeUsageEntry(L814-L922)在归一化路径中同样对这四个字段做了展开,且terminalStatus/closeReason走校验而非真值判断:
...(entry.errorCode ? { errorCode: entry.errorCode } : {}), ...(isRequestTerminalStatus(entry.terminalStatus) ? { terminalStatus: entry.terminalStatus } : {}), ...(isRequestCloseReason(entry.closeReason) ? { closeReason: entry.closeReason } : {}), ...(entry.upstreamError ? { upstreamError: entry.upstreamError } : {}),这一"白名单式归一化"与surface、transportPhase等字段遵循同一纪律:手改过的损坏行携带未知值时会丢弃该字段而非污染枚举。
实现拆解二:addRequestLog的失败门槛与字段投影
写入侧的改动位于 src/server/request-log.ts 的addRequestLog。核心是仅在失败条目上构造failureDiagnostics并展开进appendUsageEntry:
const failureDiagnostics = entry.status >= 400 || (entry.terminalStatus && entry.terminalStatus !== "completed") ? { ...(entry.errorCode ? { errorCode: entry.errorCode } : {}), ...(entry.terminalStatus ? { terminalStatus: entry.terminalStatus } : {}), ...(entry.closeReason ? { closeReason: entry.closeReason } : {}), ...(entry.upstreamError ? { upstreamError: entry.upstreamError } : {}), } : {}; appendUsageEntry({ /* 既有字段逐字段重建 */, ...failureDiagnostics });这个"逐字段重建"而非整体 spread 的设计值得注意:addRequestLog注释明确指出,任何在函数体内漏写的字段都会"到达/api/logs却永远到不了usage.jsonl"(L590-L592),因为按 key 的 rollup 正是从usage.jsonl读取的。failureDiagnostics在重建序列的靠后位置展开(L634),紧随transportPhase/terminalSource之后。
门槛语义
status >= 400:覆盖所有 HTTP 失败;其中499 client-cancel 被刻意纳入——客户端主动取消同样具有诊断价值(见 030_5xx_persistence.md 的说明)。terminalStatus !== "completed":捕获"HTTP 200 但语义上未完成"的情况,例如max_output_tokens截断导致的incomplete,或 SSE 中途failed。这正是 request-outcome.ts 中classifyRequestOutcome强调"先读语义终端、后读数字状态"的原因:一个 200 携带 incomplete terminal 的行是失败,只有数字状态会把它误判为成功。- 成功条目(2xx + completed):
failureDiagnostics为空对象{},落盘形态与历史完全一致,文件增长速率影响最小。
脱敏与截断:上游错误文本的落盘边界
upstreamError不会原样落盘。它的清洗发生在捕获时刻而非持久化时刻,位于captureUpstreamError/captureUpstreamErrorParsed(src/server/request-log.ts):
- 优先取
response.failedSSE 负载error.message或非流式 JSON 错误体中的第一条非空原因(保留原始失败,避免被后续重试覆盖); - 统一经过
redactSecretString(...)脱敏(来自 src/lib/redact.ts),再slice(0, 500)截断,保证密钥永不进入/api/logs与usage.jsonl; - 无人类可读错误消息时,回退到 bridge 发出的
response.incomplete结构化原因(如max_output_tokens、upstream_stall_timeout、adapter_eof),映射为面向读者的标签(incompleteReasonLabel)。
因此持久化侧无需再做任何额外处理,PersistedUsageEntry.upstreamError的注释"already redacted + capped at capture"就是这条链路的契约声明。
四个诊断字段速查
| 字段 | 类型 | 触发条件 | 语义与来源 |
|---|---|---|---|
errorCode | string(可选) | status >= 400或非completed终端 | HTTP 映射错误码(如 502 →upstream_server_error) |
terminalStatus | "completed" \| "failed" \| "incomplete" | 同上 | 语义终端,来自上游 terminal frame,作为分组键 |
closeReason | "terminal" \| "client_cancel" \| "non_stream" \| "body_stall" \| "body_overflow" | 同上 | 响应体为何停止被读取 |
upstreamError | string(可选) | 同上 | 已脱敏 + 500 字符截断的上游错误原因 |
验收标准与断言路径
030_5xx_persistence.md 定义的验收标准(Accept criteria)要求新增tests/usage-failure-persistence.test.ts:
- 失败条目四字段齐备:以
OPENCODEX_HOME=<tmpdir>隔离环境(依赖 src/config.ts 中 per-call 的环境变量解析,可在进程中途覆盖),通过addRequestLog写入status: 502、terminalStatus: "failed"、closeReason: "terminal"、upstreamError的条目,然后断言usage.jsonl最后一行 JSON 中四个字段全部存在。 - 成功条目形态不变:
status: 200且terminalStatus: "completed"的条目,诊断字段缺席,保持既有形状。
之所以只能走 env-var 隔离路径,是因为addRequestLog直接调用appendUsageEntry(无注入 seam),测试中另有一条断言路径:observeRequestLogsForTests(src/server/request-log.ts)可在内存环上观察落库前的条目,配合 tests/claude-integration/claude-messages-endpoint.test.ts 中已有的terminalStatus: "failed"+closeReason: "terminal"断言模式交叉验证(如该文件 L773-L774)。完整验证矩阵见 000_plan.md 的 C-ACTIVATION-GROUNDING-01 一节,其中"失败持久化"一行正是本文所述行为。
显式排除项(Out of scope)
为避免范围蔓延,030_5xx_persistence.md 明确列出:
- 不新建日志文件、不改轮转策略、不做 GUI 暴露;
- 不修改 200 条环形缓冲区大小——持久化已达成目的,缓冲区扩缩容无必要。
复盘价值:从"看到 502"到"解释 502"
该改动让 opencodex 的每次上游失败都留下一条可解释、可检索、可聚合的持久化记录:usage.jsonl中的失败行现在同时携带 HTTP 状态、语义终端、关闭原因与脱敏错误文本,运维既可以用terminalStatus: "failed"精确圈出 SSE 中途失败,也可以用status >= 400圈出 pre-stream 拒绝,还能用closeReason: "client_cancel"区分用户主动放弃。它没有发明新的协议,只是把已经在内存里存在的事实,以兼容旧读取器的形式搬到了磁盘上——这正是 260716 事件教给项目的最小代价教训:诊断字段若只活在内存里,就等于不存在。
【免费下载链接】opencodex
Universal provider proxy for OpenAI Codex & Claude Code — use any LLM (Claude, Gemini, Grok, DeepSeek, Ollama…) with Codex CLI, App, SDK, and Claude Code
相关推荐
三分钟制作专业有声书:abogen跨平台AI语音生成终极指南
三分钟制作专业有声书:abogen跨平台AI语音生成终极指南 还在为制作有声书而烦恼吗?想将PDF、EPUB文档一键转换为专业级语音内容吗?今天我要为你介绍一个
AI 应用语音音频媒体生成本地部署告别会话中断:WezTerm持久化方案让你的工作永不丢失
告别会话中断:WezTerm持久化方案让你的工作永不丢失 你是否经历过这样的场景:SSH连接突然断开导致远程任务中断,本地终端意外关闭丢失重要工作状态,或者需要
桌面应用开发工具跨平台Vector gRPC 解压失败错误详情修复:让 `vector` / `opentelemetry` 源的压缩请求故障可诊断
Vector gRPC 解压失败错误详情修复:让 vector / opentelemetry 源的压缩请求故障可诊断 导读 在 Vector 的 gRPC 系
可观测性数据工程数据集成日志分析
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考