news 2026/9/23 14:44:53

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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/opencodex

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

opencodex 是一个面向 OpenAI Codex 与 Claude Code 的通用 Provider 代理:它把 Codex CLI/App/SDK 以及 Claude Code 的请求路由到任意 LLM(Claude、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 testbun x tsc --noEmit即可复现文末验证结果。仓库为只读,本文只介绍查看与运行方式。

背景:Codex 多轮续接的两种 400

Codex 客户端进行多轮对话时,通过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(也拒绝metadatamax_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 给出了关键调查结论:

  1. codex-rs 的 HTTPResponsesApiRequest根本没有previous_response_id字段,只有 WebSocket 的ResponseCreateWsRequest携带它(用于 prefix-continuation 复用)。
  2. ocx 的 WS 服务器把response.create转换成内部 HTTP 请求,走共享的handleResponses→ forward 适配器 → ChatGPT 后端 HTTP。也就是说 WS 的“链式续接”最终落在 ChatGPT HTTP 端点上。
  3. 旧实现只在内存存储成功展开时才剥离该字段(stripExpandedPreviousResponseId,位于src/adapters/openai-responses.tssrc/server.tsexpandPreviousResponseInput);展开未命中(miss)时原样转发,于是 ChatGPT 后端必然 400。

展开未命中的四种典型场景

  • 代理重启:状态存储在内存Mapsrc/responses/state.tsconst states = new Map<...>()),进程一死就丢;
  • 1 小时 TTL 过期(后来提升为 24 小时,见下);
  • 1000 条上限淘汰;
  • 上一轮不是completed状态;
  • 上一轮由 native passthrough 服务——passthrough 分支从不调用rememberResponseState,因此续接状态里根本没有上一轮;
  • store:false守卫:src/responses/state.tsrememberResponseStatestore === false直接跳过,而 codex-rs 在非 Azure HTTP 上总是发送store:false,WS 继承之——即便接上记录逻辑,passthrough 轮次也会被跳过。

关键差异:只有openai-responses(及其 azure 包装)序列化_rawBody并原样转发;所有 routed 适配器(openai-chat/anthropic/google/kiro/cursor)都会重建请求体,从不转发该字段。

修复 Phase 1:forward 模式无条件剥离 + 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.ts

export 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"(或incompletereason === "max_output_tokens")时入库;
  • 记录items = [...requestItems, ...response.output]providerOutputStart = requestItems.length,标记 provider 输出起点,供后续“重叠跳过”判定使用;
  • 若携带 Cursor conversation id(providerState.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_outputfunction_call以及local_shell_call(codex-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 4(040_phase4_state_snapshot.md)用尽力而为的磁盘快照关闭该缺口,当前源码已完整落地:

  • 快照文件join(getConfigDir(), "responses-state.json"),懒加载(ensureLoaded在首次访问状态时执行),环境变量OPENCODEX_HOME可重定向(测试用);
  • 防抖持久化SNAPSHOT_DEBOUNCE_MS = 2_000,按上次快照大小线性拉长、上限SNAPSHOT_DEBOUNCE_MAX_MS = 30_000,timer.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,已修复:防抖写入必须在调度时刻捕获路径);
  • 优雅停机flushResponseStatedrainAndShutdown中被调用,先排空 spill 发布再落盘快照。

快照的信任边界与auth.json一致(0600 权限、同一配置目录),多实例共享同一主目录时是 last-writer-wins——单用户工具可接受。当前RESPONSE_TTL_MS已从 1 小时提升到 24 小时(src/responses/state.ts59 行),并配套内存/磁盘双预算控制,注释明确说明“保留不再是上限,预算才是”。

Bug B 根因:Anthropic 消息尾没有 role 守卫

src/adapters/anthropic.tsmessagesToAnthropicFormat在修复前没有尾角色守卫:历史以纯文本 assistant 消息结尾时,会被原样作为最后的role:"assistant"消息发出,较新的 Anthropic 模型以 prefill 拒绝。

可触达的三种尾部形态:

  1. previous_response_id展开时新input为空/缺失——展开逻辑会把上一轮输出追加在最后(src/responses/state.ts44 行逻辑),形成 assistant 尾;
  2. 被中断/压缩后重放的历史恰好以 assistant 消息结尾;
  3. web-search sidecar 首轮迭代携带 assistant 尾历史(src/web-search/loop.ts153-199 行)。

安全边界:assistanttool_use尾已经安全——messagesToAnthropicFormat会在 tool_use 后注入合成的tool_resultuser 消息(anthropic.ts369-395 行区域),因此守卫只对纯文本/thinking 的 assistant 尾与空messages数组生效。空数组本身也是非法输入。

修复 Phase 2:Anthropic 尾守卫(tail guard)

020_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.tscreateAnthropicAdapter(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.ts:forward 模式无条件剥离(含未展开 miss 仍剥离,断言body.previous_response_idundefinedinput长度不变)+ API-key 双模式矩阵(未展开保留、展开后剥离)+ 孤儿项修复相关用例;
    • tests/responses/responses-state.test.tsforce: truestore: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;快照覆盖重启场景,不覆盖超过 TTL(24h)的链;
  • 已运行的 ocx 实例必须重启才能生效(内存态修复);
  • 判定失效器:重启后 <1h 记录的 id 若仍在日志出现 miss warn,即说明快照链路有问题。

关键源码地图

  • 续接状态核心:src/responses/state.ts(rememberResponseStateexpandPreviousResponseInput、快照读写、spill 预算)
  • forward 剥离与清洗:src/adapters/openai-responses/canonical-forward.ts(stripPreviousResponseIdstripUnsupportedForwardParamsnormalizeCanonicalForwardContinuationEnvelope
  • passthrough 续接缓存:src/server/responses/passthrough-dispatch.ts(记录守卫、console.warnmiss)
  • Anthropic 尾守卫:src/adapters/anthropic.ts(messagesToAnthropicFormat837-845 行)
  • 回归测试:tests/responses/openai-responses-passthrough.test.tstests/responses/responses-state.test.tstests/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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/23 14:44:11

SSH密钥管理与Git权限问题解决方案

1. 问题现象与初步诊断每次看到终端里跳出"Permission denied (publickey)"的红色错误提示&#xff0c;作为开发者都会心头一紧。这个看似简单的权限问题&#xff0c;实际上可能涉及SSH密钥管理、远程仓库配置、系统权限设置等多个技术环节的故障。最近在团队协作中&…

作者头像 李华
网站建设 2026/9/23 14:42:00

晶闸管整流直流电动机调速系统:主电路、双闭环与参数整定全解析

简介&#xff1a;面向电气工程及自动化专业学生与电力电子技术初学者&#xff0c;这是一份晶闸管整流直流电动机调速系统的课程设计文档。内容以三相桥式全控整流电路为依托&#xff0c;完整讲解转速电流双闭环控制结构、主电路参数计算、基于TCA785集成触发芯片的移相触发原理…

作者头像 李华