ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

opencodex 响应续接修复实录:previous_response_id 400 与 Anthropic prefill 400 的根因与修复路线

opencodex 响应续接修复实录:previous_response_id 400 与 Anthropic prefill 400 的根因与修复路线 opencodex 响应续接修复实录previous_response_id 400 与 Anthropic prefill 400 的根因与修复路线【免费下载链接】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/opencodexopencodex 响应续接修复实录previous_response_id 400 与 Anthropic prefill 400 的根因与修复路线opencodex 是一个面向 OpenAI Codex 与 Claude Code 的通用 Provider 代理它把 Codex CLI/App/SDK 以及 Claude Code 的请求路由到任意 LLMClaude、Gemini、Grok、DeepSeek、Ollama 等。当 Codex 客户端以previous_response_id做多轮续接、或 Claude 系模型被喂入以 assistant 消息结尾的历史时代理可能间歇性收到两类上游 400{detail:Unsupported parameter: previous_response_id}与Provider error 400: ... This model does not support assistant message prefill...。本文基于仓库开发日志 devlog/_fin/260706_previous-response-id-400 及其后续阶段文档完整还原两个 Bug 的根因、分阶段修复方案、落地后的源码实现与回归测试证据读者读完后可以掌握forward 直通模式与 API-key 模式下previous_response_id的差异化处理策略、续接状态replay store的记录与磁盘快照机制以及 Anthropic 消息尾兜底tail guard的设计与验证方法。说明文中所引行号与常量均以当前仓库快照为准运行bun test、bun x tsc --noEmit即可复现文末验证结果。仓库为只读本文只介绍查看与运行方式。背景Codex 多轮续接的两种 400Codex 客户端进行多轮对话时通过previous_response_id引用上一轮响应只发送“新增的一轮增量”delta期望代理或上游持有此前的完整上下文。opencodex 在本地维护一个续接状态存储replay state store把previous_response_id展开expand为完整的历史input再转发。围绕这一机制存在两个独立的 400 故障Bug A{detail:Unsupported parameter: previous_response_id}HTTP 400。该错误体是 ChatGPT Codex 后端chatgpt.com/backend-api/codex/responses的典型返回被 passthrough 直通分支原样透传旧版src/server.ts486-493 行的 relay 逻辑而 routed 适配器会把错误包装成Provider error N: ...606-610 行。因此这个错误特征 原生 forward 直通路径。外部证据表明 ChatGPT Codex REST 后端对该参数采取严格白名单一律拒绝previous_response_id也拒绝metadata、max_output_tokens而公开的 OpenAI 平台/v1/responses支持该参数有真实的服务端存储。Bug BProvider error 400:前缀说明来自 routed 适配器包装错误体是 Anthropic 的错误assistant message prefill被拒。较新的 Anthropic 模型要求对话必须以 user 消息结尾若历史以纯文本 assistant 消息结尾就会触发This model does not support assistant message prefill. The conversation must end with a user message.。Bug A 根因WS 链式续接被转成内部 HTTP直通转发必然 400开发日志 000_plan.md 给出了关键调查结论codex-rs 的 HTTPResponsesApiRequest根本没有previous_response_id字段只有 WebSocket 的ResponseCreateWsRequest携带它用于 prefix-continuation 复用。ocx 的 WS 服务器把response.create转换成内部 HTTP 请求走共享的handleResponses→ forward 适配器 → ChatGPT 后端 HTTP。也就是说 WS 的“链式续接”最终落在 ChatGPT HTTP 端点上。旧实现只在内存存储成功展开时才剥离该字段stripExpandedPreviousResponseId位于src/adapters/openai-responses.ts与src/server.ts的expandPreviousResponseInput展开未命中miss时原样转发于是 ChatGPT 后端必然 400。展开未命中的四种典型场景代理重启状态存储在内存Mapsrc/responses/state.ts中const states new Map...()进程一死就丢1 小时 TTL 过期后来提升为 24 小时见下1000 条上限淘汰上一轮不是completed状态上一轮由 native passthrough 服务——passthrough 分支从不调用rememberResponseState因此续接状态里根本没有上一轮store:false守卫src/responses/state.ts中rememberResponseState对store false直接跳过而 codex-rs 在非 Azure HTTP 上总是发送store:falseWS 继承之——即便接上记录逻辑passthrough 轮次也会被跳过。关键差异只有openai-responses及其 azure 包装序列化_rawBody并原样转发所有 routed 适配器openai-chat/anthropic/google/kiro/cursor都会重建请求体从不转发该字段。修复 Phase 1forward 模式无条件剥离 passthrough 状态记录按 010_phase1_forward_strip_and_state.md审计结论为 PASS-WITH-FIXES落地三处修改1. 剥离逻辑src/adapters/openai-responses/canonical-forward.ts新增stripPreviousResponseId(body, strip)strip条件为provider.authMode forward或本地已展开parsed._previousResponseInputExpanded。实现如下export function stripPreviousResponseId(body: unknown, strip: boolean): unknown { if (!strip || !isPlainObject(body) || !Object.prototype.hasOwnProperty.call(body, previous_response_id)) return body; const { previous_response_id: _previousResponseId, ...rest } body; return rest; }forward 模式ChatGPT Codex 后端对该参数严格拒绝无条件剥离只会让结果更好且 WS 轮次已转换为内部 HTTP不存在“原生 WS 链式续接”可被破坏。API-key 模式/v1/responses平台支持该参数 真实服务端存储保持原语义——未展开时保留字段仅在本代理展开后才剥离。同一文件中的stripStatefulResponsesParams还说明了一个边界DeepSeek 等“无状态”上游同样不支持previous_response_id会在更上游的清洗链中一并丢弃并强制store:false与展开状态无关。2. passthrough 分支记录状态src/server/responses/passthrough-dispatch.ts现状源码中已落地完整的“passthrough 续接缓存”const passthroughRecordEligible parsed._compactionRequest ! true (!parsed.previousResponseId || parsed._previousResponseInputExpanded true); const rememberPassthroughResponse passthroughRecordEligible ? (response) rememberResponseState(parsed._rawBody, response, undefined, responseStateOptions(true)) : undefined;要点对完成的 passthrough 响应调用rememberResponseState让下一轮的previous_response_id能在本地展开为完整 input而不是裸 delta 直达上游记录守卫自身previous_response_id未能展开miss的请求体绝不记录否则存下的是截断历史下一轮会重放残缺对话compaction 轮次排除_compactionRequest true时不记录——_rawBody仍携带压缩前的完整历史记录它会让后续展开重新水合 Codex 刚替换掉的旧链展开 miss 时输出console.warn含 id 与 model便于诊断“截断上下文”的轮次。3.rememberResponseState增加force选项src/responses/state.tsexport function rememberResponseState( requestBody: unknown, response: { id?: unknown; output?: unknown; status?: unknown; incomplete_details?: unknown }, providerState?: OcxProviderContinuationState | string, opts?: { force?: boolean; clientThreadId?: string }, ): void { ... // force bypasses only the store:false skip: Codex sends store:false on every non-Azure // HTTP request (and WS inherits it), yet its WS turns still chain with previous_response_id. if (request.store false !opts?.force) return; ... }force: true只绕过store false这一个跳过条件把 passthrough 轮次也纳入代理内部续接缓存既有调用方语义不变。记录时还会仅在status completed或incomplete且reason max_output_tokens时入库记录items [...requestItems, ...response.output]与providerOutputStart requestItems.length标记 provider 输出起点供后续“重叠跳过”判定使用若携带 Cursor conversation idproviderState.cursor.conversationId则保留之并标记checkpointUsable !output.some(item item.type function_call)——以 pending 客户端工具调用结尾的轮次其 checkpoint 不得复用。expandPreviousResponseInput侧src/responses/state.ts1046 行起还会做作用域匹配clientThreadId必须一致否则以scope_mismatch拒绝重放以及重叠跳过当客户端已完整携带历史匹配到 provider 发行的 item id时不再重复前置历史防止上下文指数膨胀日志中记录的 127k token 涨到 1.3M 的真实案例。Phase 3 附带修复剥离后的孤儿 input 400剥离previous_response_id后出现新问题未命中的 delta 请求被转发时其function_call_output没有配对的function_call上游返回No tool call found for function call output with call_id ...。修复为repairOrphanedInputItemssrc/adapters/openai-responses/下每次 forward 请求都运行配对完好时 no-op孤儿function_call_output/custom_tool_call_output→ 转为 userinput_text消息信息保留function_call_output与function_call以及local_shell_callcodex-rs 会把 shell 输出发成function_call_output配对时保持原样仅未展开 miss 时丢弃 reasoning 类孤儿项rs_*项若被剥离会以 “provided without its required following item” 400。最终效果miss 从“必然 400”降级为“上下文降级但可继续”工具输出转 user 文本、reasoning 丢弃模型可能在该轮丢失部分细微信息——这是可接受的取舍。Phase 4续接状态磁盘快照重启韧性状态存储是内存Map重启即丢链——这正是“patch → ocx 重启 → 展开 miss → 上游 400”的真实触发链。Phase 4040_phase4_state_snapshot.md用尽力而为的磁盘快照关闭该缺口当前源码已完整落地快照文件join(getConfigDir(), responses-state.json)懒加载ensureLoaded在首次访问状态时执行环境变量OPENCODEX_HOME可重定向测试用防抖持久化SNAPSHOT_DEBOUNCE_MS 2_000按上次快照大小线性拉长、上限SNAPSHOT_DEBOUNCE_MAX_MS 30_000timer.unref?.()不阻塞进程退出persistNow尽力而为任何磁盘错误都吞掉——快照是缓存不是真相源加载校验version 1 | 2、数组条目[string, StoredResponseState]、数值型createdAt损坏/缺失文件一律忽略并空载加载后立即pruneResponses()清理过期项容量上限单条 2 MiB、总量 24 MiB写入侧读取侧拒收 32 MiB 的文件防外部植入超大文件还有 64 MiB 内存驻留上限与 1 GiB 磁盘 spill 上限全部“最旧优先”淘汰测试隔离clearResponseStateForTests会取消定时器、重置loaded、删除快照文件测试通过临时OPENCODEX_HOME沙箱避免污染真实主目录早期全量跑时曾把快照写进真实~/.opencodex/responses-state.json已修复防抖写入必须在调度时刻捕获路径优雅停机flushResponseState在drainAndShutdown中被调用先排空 spill 发布再落盘快照。快照的信任边界与auth.json一致0600 权限、同一配置目录多实例共享同一主目录时是 last-writer-wins——单用户工具可接受。当前RESPONSE_TTL_MS已从 1 小时提升到 24 小时src/responses/state.ts59 行并配套内存/磁盘双预算控制注释明确说明“保留不再是上限预算才是”。Bug B 根因Anthropic 消息尾没有 role 守卫src/adapters/anthropic.ts的messagesToAnthropicFormat在修复前没有尾角色守卫历史以纯文本 assistant 消息结尾时会被原样作为最后的role:assistant消息发出较新的 Anthropic 模型以 prefill 拒绝。可触达的三种尾部形态previous_response_id展开时新input为空/缺失——展开逻辑会把上一轮输出追加在最后src/responses/state.ts44 行逻辑形成 assistant 尾被中断/压缩后重放的历史恰好以 assistant 消息结尾web-search sidecar 首轮迭代携带 assistant 尾历史src/web-search/loop.ts153-199 行。安全边界assistanttool_use尾已经安全——messagesToAnthropicFormat会在 tool_use 后注入合成的tool_resultuser 消息anthropic.ts369-395 行区域因此守卫只对纯文本/thinking 的 assistant 尾与空messages数组生效。空数组本身也是非法输入。修复 Phase 2Anthropic 尾守卫tail guard020_phase2_anthropic_tail_guard.md 的方案在messagesToAnthropicFormat的返回路径内追加兜底——每个调用方buildRequest及未来复用都自动获得该不变式。当前源码src/adapters/anthropic.ts837-845 行已落地// Newer Anthropic models reject assistant-tail histories as prefill: // This model does not support assistant message prefill. The conversation must end with a user message. // previous_response_id expansion with empty new input, interrupted-turn replay, and web-search sidecar // first iterations can all reach this; Kiro uses the same (continue) nudge precedent. if (messages.length 0) { messages.push({ role: user, content: (continue) }); } else if ((messages[messages.length - 1] as { role?: string }).role assistant) { messages.push({ role: user, content: (continue) }); }规则messages.length 0→ 追加单条 user(continue)末条role assistant→ 追加 user(continue)末条为 user、或 assistant tool_use 已被 tool_result 跟随 → 不追加无双重 nudge仓库内先例kiro 适配器在连续 assistant 之间、以及作为兜底当前消息插入 user(continue)src/adapters/kiro.ts283、309-317 行区域。回归测试tests/adapters/anthropic/anthropic-tail-guard.test.ts用createAnthropicAdapter(provider).buildRequest构造真实 wire 请求并断言四条场景断言上下文以 assistant 文本结尾wire messages 末条为 user(continue)上下文以 user 结尾原样无额外 nudge空上下文单条 user(continue)assistant tool_use toolResult 结尾长度 2末条 role 为 user且不含(continue)验证与收尾合并阶段030_done.md 与 999_closed.md记录了完整验收证据全量回归bun test ./tests/多次全绿Phase 2 后 1498 通过/0 失败Phase 3 后 1501 通过Phase 4 后 1505 通过收尾时 1555 通过/0 失败157 文件bun x tsc --noEmitexit 0新回归测试tests/responses/openai-responses-passthrough.test.tsforward 模式无条件剥离含未展开 miss 仍剥离断言body.previous_response_id为undefined、input长度不变 API-key 双模式矩阵未展开保留、展开后剥离 孤儿项修复相关用例tests/responses/responses-state.test.tsforce: true在store:false下仍记录并支持下一轮展开无 force 时store:false依旧跳过快照 roundtrip内存清空 磁盘加载 conversationId、加载时 TTL 剪枝、损坏文件忽略、超大条目跳过tests/adapters/anthropic/anthropic-tail-guard.test.ts上述 4 条尾守卫用例。残留风险与运维提示**routed 适配器openai-chat/anthropic/google/kiro/cursor**在展开 miss 时仍会静默降级为 delta-only 上下文不 400 但丢上下文passthrough 的 warn 不覆盖 routed 路径——列为 watch item状态存储本质仍是内存 尽力快照重启韧性已显著改善但多实例共享主目录是 last-writer-wins快照覆盖重启场景不覆盖超过 TTL24h的链已运行的 ocx 实例必须重启才能生效内存态修复判定失效器重启后 1h 记录的 id 若仍在日志出现 miss warn即说明快照链路有问题。关键源码地图续接状态核心src/responses/state.tsrememberResponseState、expandPreviousResponseInput、快照读写、spill 预算forward 剥离与清洗src/adapters/openai-responses/canonical-forward.tsstripPreviousResponseId、stripUnsupportedForwardParams、normalizeCanonicalForwardContinuationEnvelopepassthrough 续接缓存src/server/responses/passthrough-dispatch.ts记录守卫、console.warnmissAnthropic 尾守卫src/adapters/anthropic.tsmessagesToAnthropicFormat837-845 行回归测试tests/responses/openai-responses-passthrough.test.ts、tests/responses/responses-state.test.ts、tests/adapters/anthropic/anthropic-tail-guard.test.ts小结这一轮修复的实质是把两个“硬失败”转化为“可诊断的软降级 尽力恢复”forward 模式下previous_response_id从“必 400”变为“无条件剥离 本地续接缓存 孤儿项修复”API-key 模式保留平台语义Anthropic 侧以(continue)nudge 兜住所有非法消息尾最后用 24h TTL、双字节预算与磁盘快照把续接状态从“进程内易失缓存”升级为“重启可恢复的本地缓存”。如果你正在为 Codex/Claude 生态搭建代理层这套“区分直通与路由模式、剥离参数前先补全本地状态、对上游严格白名单做防御性清洗”的思路可以直接复用。 /output article【免费下载链接】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),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进