【免费下载链接】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 与 Claude Code 的通用 Provider 代理,其 Phase 100(Codex Native Parity)的目标是让被路由翻译后的模型流看起来与原生 Codex Responses 完全一致。本文以 52_error-header-fidelity-implementation-plan.md 为核心,系统讲解其中"错误与响应头保真"这一子阶段:如何用共享错误分类器把上下文超限、配额不足、限流等失败映射为 Codex 认识的标准错误码,如何让流式response.failed同时携带error与last_error,以及如何在透传上游响应头时收紧 hop-by-hop / 陈旧头过滤而绝不伪造限流头。读完本文,你将掌握该功能从实现计划、源码落点到测试验证的完整链路,可直接在仓库中对照复现。
一、背景:Codex RS 如何分类流式失败,以及 opencodex 的现状缺口
在 Codex 原生协议中,上游codex-rs解析器(对应源码位于codex-api/src/sse/responses.rs,计划文档引用其 347、532、557 行附近)只从response.failed.response.error.code这一个字段读取流式失败分类。也就是说,Codex 客户端判定"上下文窗口超限""配额用尽""被限流"等失败族,完全依赖该错误负载里的code与type。
而本计划落地前的 opencodex 翻译流只发射:
response.failed.response.last_error缺少error字段。其直接后果是:上游模型返回的 context-window、quota、rate-limit 失败,到了 Codex 客户端眼里全部退化成"泛化的流错误"(generic stream error),客户端既无法给出准确的失败提示,也无法按正确的重试语义处理。本阶段的核心任务正是补齐这条"保真链路":
- 新增一个共享错误分类器
classifyError,把原始状态码 + 错误类型 + 消息文本统一映射为 OpenAI/Codex 兼容的错误负载; - 流式
response.failed同时发射error与last_error,兼顾 Codex 分类读取与既有兼容性; - 收紧透传响应头净化:剔除 hop-by-hop 与陈旧头,同时保留上游真实的
x-ratelimit-*、openai-*、request-id等元数据头,并且绝不合成限流头——因为 opencodex 对翻译型 Provider 没有完整的上游配额遥测,凭空捏造会误导客户端。
计划中对应本地缺陷定位在当时的src/bridge.ts与src/server.ts(计划文档中记录的本地路径为开发机绝对路径,仓库现址为 src/bridge 目录与 src/server 目录)。
二、共享错误分类器:classifyError的设计与完整实现
计划新增的核心文件是错误分类器模块(计划中的src/errors.ts;当前仓库中它已演化为 src/lib/errors.ts)。其数据模型与完整实现如下:
export interface OcxErrorPayload { message: string; type: string; code: string | null; } export function classifyError(status: number, type: string, message: string): OcxErrorPayload { const text = message.toLowerCase(); if (text.includes("context_length_exceeded") || text.includes("context window") || text.includes("context length") || text.includes("maximum context") || text.includes("too many tokens")) { return { message, type: "invalid_request_error", code: "context_length_exceeded" }; } if (text.includes("insufficient_quota") || text.includes("quota exceeded") || text.includes("exceeded your current quota")) { return { message, type: "insufficient_quota", code: "insufficient_quota" }; } if (status === 429 || text.includes("rate limit") || text.includes("too many requests")) { return { message, type: "rate_limit_error", code: "rate_limit_exceeded" }; } if (status === 401 || status === 403 || type === "authentication_error") { return { message, type: "authentication_error", code: "invalid_api_key" }; } if (status >= 500) { return { message, type: "server_error", code: "upstream_server_error" }; } if (status === 400 || type === "invalid_request_error") { return { message, type: "invalid_request_error", code: "invalid_request_error" }; } return { message, type, code: type || null }; }2.1 判定顺序与语义要点
classifyError是一串按优先级排列的规则链,其设计意图是让最具体、最可行动的失败语义先命中:
| 判定条件 | 产出 type | 产出 code | 说明 |
|---|---|---|---|
消息含context_length_exceeded/context window/context length/maximum context/too many tokens | invalid_request_error | context_length_exceeded | 上下文窗口超限,Codex 将其视为终态失败 |
消息含insufficient_quota/quota exceeded/exceeded your current quota | insufficient_quota | insufficient_quota | 配额不足,同样是终态 |
status === 429或消息含rate limit/too many requests | rate_limit_error | rate_limit_exceeded | 限流,客户端会按 Retry-After 语义处理 |
status === 401/403或type === "authentication_error" | authentication_error | invalid_api_key | 认证失败 |
status >= 500 | server_error | upstream_server_error | 上游服务器错误,可重试 |
status === 400或type === "invalid_request_error" | invalid_request_error | invalid_request_error | 客户端请求非法 |
| 其余情况 | 原样透传 | type或null | 未知类型保持原样,不强行归类 |
三个关键设计决策值得注意:
- 消息关键词优先于裸状态码:
context window、quota exceeded等文本信号能命中"上游把 200 流中间断掉但错误文本却带有语义"的情况;只有当消息无法给出线索时才回退到 HTTP 状态码推断。 - 限流与配额严格区分:
rate_limit_exceeded(可重试、可退避)与insufficient_quota(终态、需充值/升级)是两套完全不同的客户端行为,分类器刻意将它们分开,避免把"配额用尽"误报成"限流"。 - 兜底不虚构:无法识别的
type原样透传、code置空或沿用type,绝不硬造一个误导性的错误码。
2.2 仓库中的演进:分类器如何被持续加固
计划文档给出的是第一版实现;对照当前仓库 src/lib/errors.ts,可以看到该分类器后续被大幅扩展,但规则链的顺序哲学保持一致。例如当前版本新增了:
cyber_policy(网络安全策略拒绝,Codex 仅在error.code === "cyber_policy"时展示专用 UI);client_closed_request(HTTP 499 / 客户端主动取消,映射为invalid_request_error);permission_error/permission_denied(403 权限拒绝)、location_not_supported(地理拒绝)、subscription_required(订阅门槛);server_is_overloaded(503 / "server is busy",Codex 识别该码并应用 retry-after 退避);- 针对 Cursor 适配器的
tool_catalog_too_large、cursor rate limit exceeded前缀权威判定等。
同时计划中的"修改formatErrorResponse"在仓库中同样已落地:当前实现位于 src/bridge/errors.ts,formatErrorResponse(status, type, message)直接调用classifyError生成 OpenAI 兼容的{ error: { message, type, code } }JSON 包络,并额外支持options.code/options.retryAfter白名单覆盖与Retry-After响应头注入。
三、桥接层改造:流式response.failed同时发射error与last_error
计划对桥接层(当时的src/bridge.ts,仓库现址 src/bridge/sse.ts)做三处改动。
3.1 引入分类器与辅助函数
在模块头部导入分类器,并在responsesUsage()附近新增一个薄封装:
import type { AdapterEvent, OcxUsage } from "./types"; +import { classifyError } from "./errors";+function responseError(status: number, type: string, message: string): Record<string, unknown> { + return classifyError(status, type, message); +}当前仓库 src/bridge/sse.ts 中的实现即为这一形态(返回值类型从Record<string, unknown>精确化为OcxErrorPayload):
function responseError(status: number, type: string, message: string): OcxErrorPayload { return classifyError(status, type, message); }之所以保留这一层薄封装,是因为桥接层的所有失败出口都统一走responseError(...),未来若要在分类前做消息脱敏(例如 src/bridge/sse.ts 中对代理内部异常调用redactSecretString后再分类),只需改动这一个函数。
3.2 适配器错误事件:error+last_error双写
适配器上报error事件时,流式终止帧从"只写last_error"改为"error与last_error双写":
emit("response.failed", { response: { ...responseSnapshot("failed", finishedItems), - last_error: { type: "upstream_error", message: event.message }, + error: responseError(502, "upstream_error", event.message), + last_error: responseError(502, "upstream_error", event.message), }, });注意此处默认状态码502+ 类型upstream_error只作为分类器的输入;真正落到code上的分类结果(如context_length_exceeded)由classifyError依据消息文本推导,这正是本阶段"保真"的关键。当前仓库中该路径已由adapterFailureFromEvent承担(见 src/bridge/sse.ts),其产物同样以error: failure.error, last_error: failure.error双写进response.failed,并额外携带retryable标记(cyber_policy等终态码强制retryable: false)。
3.3 桥接层捕获异常:代理侧错误也走分类器
桥接层自身的 try/catch 捕获到异常时,原来直接构造{ type: "proxy_error", message },现在同样交给分类器:
emit("response.failed", { response: { ...responseSnapshot("failed", finishedItems), - last_error: { type: "proxy_error", message: err instanceof Error ? err.message : String(err) }, + error: responseError(500, "proxy_error", err instanceof Error ? err.message : String(err)), + last_error: responseError(500, "proxy_error", err instanceof Error ? err.message : String(err)), }, });代理内部异常使用500 + proxy_error作为分类输入,使代理自身故障与上游故障(502)在语义上可区分。仓库现址 src/bridge/sse.ts 正是这一实现(并对消息先做redactSecretString脱敏、对cyber_policy结果附加retryable: false)。
3.4 JSON 错误格式化器统一走分类器
非流式路径(REST 错误响应)同样受益:
-export function formatErrorResponse(status: number, type: string, message: string): Response { - return new Response(JSON.stringify({ error: { message, type, code: null } }), { +export function formatErrorResponse(status: number, type: string, message: string): Response { + return new Response(JSON.stringify({ error: classifyError(status, type, message) }), { status, headers: { "Content-Type": "application/json" }, }); }这一改动的直接收益是:REST 与 SSE 两条路径从此共享同一套分类规则,code不再永远是null,客户端无论走流式还是非流式,看到的失败语义完全一致。
四、透传响应头净化:剔除 hop-by-hop / 陈旧头,保留真实元数据
计划对src/server.ts的改动是扩充透传响应头黑名单:
- const DROP = new Set(["content-encoding", "content-length", "transfer-encoding", "connection", "keep-alive"]); + const DROP = new Set([ + "content-encoding", + "content-length", + "transfer-encoding", + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "upgrade", + ]);4.1 为什么要丢弃这些头
- hop-by-hop 头(
connection、keep-alive、te、trailer、upgrade、transfer-encoding):语义上只对"相邻一跳"有效。代理作为中转方如果原样透传,会让 Codex 客户端误以为自己与上游直连,从而对upgrade(如 WebSocket 升级)或trailer(分块尾字段)产生错误的协议预期。 proxy-authenticate/proxy-authorization:代理认证头,绝不能泄漏给客户端(否则可能暴露内部代理的认证方案)。content-encoding/content-length:由代理重新编码/分块后,长度与压缩状态已不再成立,必须由代理侧重写。
4.2 保留什么、不合成什么
计划明确声明:该黑名单不会丢弃以下真实元数据头——
x-ratelimit-*(上游真实限流配额元数据,如x-ratelimit-limit-requests、x-ratelimit-remaining-requests);openai-*(如openai-model);request-id、content-type、模型/版本头。
与此同时,绝不合成限流头。理由在计划中有清晰交代:opencodex 对翻译型 Provider(把非 OpenAI 协议翻译成 Responses 流的适配器)没有完整的上游配额遥测,若自行伪造x-ratelimit-*,等于向客户端宣告不存在的配额事实,会误导客户端的退避与熔断决策。这是"保真"原则的另一面:宁可缺失,不可编造。
仓库现址 src/server/relay.ts 中的sanitizePassthroughHeaders已实现该逻辑,且黑名单进一步扩充了set-cookie、set-cookie2(防止上游 Cookie 泄漏到客户端),并支持可选的dropCodexSafetyBuffering过滤(对应CodexSafetyBufferingFilterOptions,用于剥离 Codex 安全缓冲相关响应头)。
五、测试验证:tests/error-fidelity.test.ts全量清单
计划配套新增了 tests/error-fidelity.test.ts(注:当前仓库中该计划文件已并入后续演化,同主题的既有测试可参见 tests/providers/cyber-policy-error-fidelity.test.ts 与tests/responses下相关用例),四个测试维度覆盖了本阶段的全部行为契约:
import { describe, expect, test } from "bun:test"; import { bridgeToResponsesSSE, formatErrorResponse } from "../src/bridge"; import { classifyError } from "../src/errors"; import { sanitizePassthroughHeaders } from "../src/server"; import type { AdapterEvent } from "../src/types"; async function* replay(events: AdapterEvent[]): AsyncGenerator<AdapterEvent> { for (const event of events) yield event; } async function collectSse(stream: ReadableStream<Uint8Array>): Promise<{ event?: string; data: Record<string, unknown> }[]> { const reader = stream.getReader(); const decoder = new TextDecoder(); let text = ""; while (true) { const { done, value } = await reader.read(); if (done) break; text += decoder.decode(value, { stream: true }); } return text.split("\n\n") .map(frame => frame.trim()) .filter(frame => frame.length > 0 && frame !== "data: [DONE]") .map(frame => { const lines = frame.split("\n"); const event = lines.find(line => line.startsWith("event: "))?.slice(7); const dataLine = lines.find(line => line.startsWith("data: ")); return { event, data: JSON.parse(dataLine?.slice(6) ?? "{}") as Record<string, unknown> }; }); } describe("error fidelity", () => { test("classifyError maps Codex-recognized context/quota/rate failures", () => { expect(classifyError(400, "upstream_error", "Your input exceeds the context window")).toMatchObject({ type: "invalid_request_error", code: "context_length_exceeded", }); expect(classifyError(429, "upstream_error", "Rate limit reached for model")).toMatchObject({ type: "rate_limit_error", code: "rate_limit_exceeded", }); expect(classifyError(402, "upstream_error", "You exceeded your current quota")).toMatchObject({ type: "insufficient_quota", code: "insufficient_quota", }); }); test("formatErrorResponse returns OpenAI-compatible classified error envelope", async () => { const response = formatErrorResponse(429, "upstream_error", "Rate limit reached for model"); expect(response.status).toBe(429); await expect(response.json()).resolves.toEqual({ error: { message: "Rate limit reached for model", type: "rate_limit_error", code: "rate_limit_exceeded", }, }); }); test("streaming response.failed includes both error and last_error", async () => { const frames = await collectSse(bridgeToResponsesSSE(replay([ { type: "error", message: "Your input exceeds the context window" }, ]), "routed/model")); const failed = frames.find(frame => frame.event === "response.failed")?.data.response as Record<string, unknown>; expect(failed.error).toMatchObject({ type: "invalid_request_error", code: "context_length_exceeded", }); expect(failed.last_error).toEqual(failed.error); }); test("sanitizePassthroughHeaders drops stale and hop-by-hop headers while preserving rate-limit metadata", () => { const sanitized = sanitizePassthroughHeaders(new Headers({ "content-encoding": "gzip", "content-length": "12", "connection": "keep-alive", "keep-alive": "timeout=5", "proxy-authenticate": "Basic", "te": "trailers", "trailer": "x-checksum", "upgrade": "websocket", "x-ratelimit-limit-requests": "100", "openai-model": "gpt-5.5", "content-type": "application/json", })); expect(sanitized.has("content-encoding")).toBe(false); expect(sanitized.has("content-length")).toBe(false); expect(sanitized.has("connection")).toBe(false); expect(sanitized.has("keep-alive")).toBe(false); expect(sanitized.has("proxy-authenticate")).toBe(false); expect(sanitized.has("te")).toBe(false); expect(sanitized.has("trailer")).toBe(false); expect(sanitized.has("upgrade")).toBe(false); expect(sanitized.get("x-ratelimit-limit-requests")).toBe("100"); expect(sanitized.get("openai-model")).toBe("gpt-5.5"); expect(sanitized.get("content-type")).toBe("application/json"); }); });5.1 四个用例分别验证什么
- 分类器正确性:
context window→invalid_request_error / context_length_exceeded;429 →rate_limit_error / rate_limit_exceeded;exceeded your current quota→insufficient_quota。注意第三个用例故意用402状态码,验证"即使上游只给 4xx,消息文本也能驱动配额分类"。 - REST 错误包络:
formatErrorResponse返回 429 状态,且 JSON body 中error.code不再是null而是rate_limit_exceeded——这是对旧行为(code: null)的直接回归验证。 - 流式双写:喂入一个纯
{ type: "error", message }适配器事件,经bridgeToResponsesSSE翻译后,response.failed帧必须同时含error与last_error,且二者相等。这直接对应"Codex 只读error.code"的兼容性需求。 - 响应头净化:
content-encoding、content-length、connection、keep-alive、proxy-authenticate、te、trailer、upgrade全部被丢弃;x-ratelimit-limit-requests、openai-model、content-type完整保留。测试用一行断言把"剔除什么 / 保留什么 / 不合成什么"的边界钉死。
collectSse辅助函数本身也是一个可复用的 SSE 帧解析器:按\n\n切帧、过滤data: [DONE]哨兵、分别提取event:与data:行,凡涉及桥接层输出的测试都可直接借用。
六、验证命令与提交规范
计划给出的完整验证门禁(在当前仓库中同样适用):
bun test tests/error-fidelity.test.ts tests/bridge.test.ts # 本阶段专项测试 bun test tests # 全量测试套件 bun x tsc --noEmit # TypeScript 类型检查 git diff --check # 空白符 / 冲突标记检查期望结果:
error fidelity tests pass full test suite passes typecheck passes diff whitespace check passes提交信息沿用项目代理提交规范:
[agent] fix: align error and header fidelity七、总结:错误与响应头"保真"的完整闭环
把本阶段放到整个 Phase 100 的语境中看(参见 00_overview.md):opencodex 通过克隆原生 Codex 模型模板让 Codex CLI/App 识别路由模型,但运行时行为必须逐项对齐。本阶段解决的正是其中"失败语义"这一环——通过classifyError统一分类、error/last_error双写、REST 与 SSE 双路径复用同一分类器、响应头"剔除该剔除的、保留真实的、绝不合成缺失的",最终让 Codex 客户端面对翻译型 Provider 时,能够像面对原生 OpenAI 一样精准识别上下文超限、配额不足、限流与认证失败。
仓库中的落点对照表:
| 计划内容 | 仓库现址 |
|---|---|
错误分类器classifyError/OcxErrorPayload | src/lib/errors.ts(接口)与 src/lib/errors.ts(分类器,已大幅演进) |
桥接辅助函数responseError | src/bridge/sse.ts |
流式response.failed双写error/last_error | src/bridge/sse.ts、src/bridge/sse.ts |
| JSON 错误格式化器 | src/bridge/errors.ts |
| 透传响应头净化 | src/server/relay.ts |
| 保真测试 | tests/providers/cyber-policy-error-fidelity.test.ts 及tests/responses相关用例 |
如需继续深入,可沿 Phase 100 的其他切片(流式 thinking 上下文 50_streaming-thinking-context.md、原始推理桥接 51_raw-reasoning-bridge.md、端到端验证计划 53_e2e-style-verification-plan.md)追完整条"原生对等"改造主线。
【免费下载链接】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
相关推荐
IPTVnator 内嵌 MPV:关闭 yt-dlp 钩子,让流连接失败即时呈现真实错误
IPTVnator 内嵌 MPV:关闭 yt dlp 钩子,让流连接失败即时呈现真实错误 本文讲解 IPTVnator 内嵌 MPV 播放器(frame cop
音视频视频桌面应用前端opencodex 流式错误分类保真:将上游限流与过载精确映射到 Codex 可识别的错误码
opencodex 流式错误分类保真:将上游限流与过载精确映射到 Codex 可识别的错误码 导读 本文围绕 opencodex(Universal provi
opencodex 流式传输、推理与上下文元数据:Codex 原生对齐的保真度分析与演进
opencodex 流式传输、推理与上下文元数据:Codex 原生对齐的保真度分析与演进 opencodex 作为面向 OpenAI Codex 与 Claud
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考