AGENT

token-pool-local-auth-chain-hang-2026-04-28

2026/04/28 51 min read AGENT AUTH TOKEN POOL LOCAL AUTH CHAIN HANG

Token Pool 本地 auth 链悬挂与 Codex 请求假断流修复记录(2026-04-28)

结论

这次本地 Codex -> Token Pool 的“像断流一样直接停住”里,已经确认并修掉两条同一 auth 链上的确定性问题:

  1. crs-key-auth 在 key 不属于 CRS key store 时,会返回 authorized: false
  2. plugin-manager.executeAuth() 又把所有 authorized: false 一律改写成 handled: true

组合结果是:

  • request-handler 误以为“认证插件已经自己把响应写回去了”
  • 实际插件并没有 res.end()
  • 请求不会继续走 handleError(401)
  • 客户端只能一直等,直到自己超时或主动断开

这不是网络断流,而是 auth 链把请求挂住了

在修掉 contract 后,又继续确认到第二个同链问题:

  • crs-key-auth 会把所有 Bearer key 都当成 CRS key 来查库
  • 只要 key 不在 configs/crs-keys.json,它就会抢先拒绝
  • 导致 default-auth 根本没有机会验证 configs/config.json 中的 REQUIRED_API_KEY

这会让“明明填的是本地配置里的 API key”仍然被误判为未授权。

现场症状

本地可稳定复现的异常现象是:

  • POST /openai/v1/responsesREQUIRED_API_KEY 时,不立即返回 401,也不继续执行业务逻辑
  • 客户端一直等响应头,直到超时
  • 日志里能看到:
    • [CRS Auth] Invalid client API key
    • 但没有紧接着的 [Request] END
    • 只有客户端超时后才出现 [Request] ABORT ... reason=request_aborted

单一路径追踪

1. 请求入口

src/handlers/request-handler.js

请求进入后先做:

  • pluginManager.executeAuth(req, res, requestUrl, currentConfig)

然后按返回值分支:

  • authResult.handled === true -> 直接 return
  • authResult.authorized === false -> handleError(...401...)

2. CRS 认证插件

src/plugins/crs-key-auth/index.js

原逻辑里:

  • 取到 Bearer key
  • configs/crs-keys.json 查不到
  • 直接返回 { handled: false, authorized: false }

3. 插件管理器错误改写 contract

src/core/plugin-manager.js

原逻辑把:

  • 插件返回 { handled: false, authorized: false }

错误改写成:

  • { handled: true, authorized: false }

4. request-handler 被误导

于是 request-handler 进入:

  • if (authResult.handled) return;

请求到此结束,但没有任何响应体被写回。

5. 客户端视角

客户端看到的不是明确 401,而是:

  • 等不到首包
  • 等不到结束
  • 最后自己超时 / 中断

这就是“看起来像断流”的直接原因。

已修复内容

A. 修正 auth contract

文件:src/core/plugin-manager.js

修复后:

  • 只有插件真的自己写回响应时,才返回 handled: true
  • 如果插件只是认证失败但没写回响应,则保留 handled: false, authorized: false
  • request-handler 统一走 401 收尾

B. 修正 CRS 插件的抢占式拒绝

文件:src/plugins/crs-key-auth/index.js

修复后:

  • key 不在 CRS store 时,不再直接判死
  • 改为返回 authorized: null
  • 让后续 default-auth 继续验证 REQUIRED_API_KEY

这让两套认证层可以按 README 描述的方式共存:

  • crs-key-auth 处理公网/控制台分发 key
  • default-auth 处理本地配置的默认 API key

回归测试

新增 / 覆盖的回归测试在:

  • tests/plugin-manager.unit.test.js

已验证:

  1. 认证插件返回 authorized: false 且未写响应时,handled 不会被错误改成 true
  2. 认证插件真的已写响应时,handled: true 会被保留
  3. 前置 auth 插件返回 authorized: null 时,后续 auth 插件仍可继续授权

执行结果:

  • node node_modules/jest/bin/jest.js tests/plugin-manager.unit.test.js --runInBand
  • 结果:4 passed

本地 live 验证

部署动作

  • 通过 POST http://127.0.0.1:3100/master/restart 重启本地 worker
  • 当前重启后 worker pid:42004

验证 1:不再悬挂

使用 REQUIRED_API_KEY 发送:

  • GET /openai/v1/models
  • POST /openai/v1/responses(故意发非法 JSON)

结果:

  • 两个请求都在本地立即完成
  • 不再出现“等不到响应头直到客户端超时”的旧现象

验证 2:日志证据

logs/app-2026-04-28.log 已出现新的明确链路:

  • [CRS Auth] Key not found in CRS store, deferring to next auth plugin
  • 紧跟 [Request] END ... status=500 ...

这说明请求已经不再卡在 auth 链,而是正常进入后续逻辑并被统一收尾。

验证 3:当前剩余错误已不属于 auth-hang

当前 GET /openai/v1/models 返回的 500 是:

  • OpenAI API Key is required for OpenAIResponsesApiService.

当前 POST /openai/v1/responses 故意发非法 JSON 返回的 500 是:

  • Invalid JSON in request body.

这两个错误都说明:

  • 认证已经通过或正常落到后续阶段
  • 剩下的是 provider/runtime 配置问题,不再是“请求悬挂”问题

本次涉及文件

代码修复:

  • E:\My Project\Token Pool\src\core\plugin-manager.js
  • E:\My Project\Token Pool\src\plugins\crs-key-auth\index.js
  • E:\My Project\Token Pool\tests\plugin-manager.unit.test.js

运行证据:

  • E:\My Project\Token Pool\logs\app-2026-04-28.log

当前状态

  • deploy decision: deployed(已重启本地 live runtime)
  • live verification: passed(auth 链不再悬挂,请求可明确结束)
  • root-cause status: confirmed and fixed
  • remaining follow-up: 本地 openaiResponses-custom 的上游 OpenAI key/provider 配置仍需单独检查,但已不属于这次 auth-hang 根因

第二阶段追加记录:Responses 流错误被错误伪装成 200 SSE(2026-04-28 16:44 +08:00)

追加结论

在 auth-hang 修掉之后,又确认出另一条独立但同样会被用户体感成“断流/突然停住”的确定性问题:

  • POST /openai/v1/responses 这类 stream 请求
  • 如果上游在 首个 chunk 之前 就失败
  • 本地 handleStreamRequest() 会先写死:
    • 200
    • Content-Type: text/event-stream
  • 然后最终错误只能退化成:
    • text/event-stream
    • 正文却是裸 JSON error

这会让下游客户端误判:

  • 它收到的不是一个正常 HTTP error
  • 也不是一个合法完成的 SSE/Responses 流
  • 于是会表现成“像断流一样不对劲”,甚至进入自己的重试/超时逻辑

这不是 provider 自身协议问题,而是 Token Pool 本地 stream 头发送时机错误

单一路径追踪

1. 旧行为

文件:src/utils/common.js

handleStreamRequest() 在真正拿到上游首个 chunk 之前,就先执行:

  • handleUnifiedResponse(res, '', true)

handleUnifiedResponse() 会立刻:

  • res.writeHead(200, { "Content-Type": "text/event-stream", ... })

2. 上游失败条件

这条链在本地 live 环境下稳定触发:

  • gpt-5.3-codex
  • 路由归一到 openai-codex-oauth
  • 上游 codex credential 返回 429 usage_limit_reached
  • runtime-overrides.json 已去掉 openai-codex-oauth -> openaiResponses-custom fallback

于是当前请求会在 没有任何首个流 chunk 的前提下直接失败。

3. 错协议收尾

因为响应头已经提前写成了 200 text/event-stream

  • 后续 handleError() 已经不能再改成真实 500 application/json
  • 客户端看到的就是“stream 头 + 非 stream body”

直接抓包的旧现场是:

  • STATUS 200
  • Content-Type: text/event-stream
  • body:
{"error":{"type":"server_error","message":"[API Service] No healthy provider found in pool for openai-codex-oauth supporting model: gpt-5.3-codex", ...}}

这就是导致 Codex/TUI 侧出现异常等待、重试或“像 turn 被截断”的直接协议层原因。

本次修复

文件:src/utils/common.js

修复动作是把 stream 头发送从“请求一进入 stream handler 就发送”改成:

  • 只有 真正要写第一个 event/data chunk 时才发送
  • 或真正正常结束、需要补 completion marker 时才发送

也就是:

  • 不再在 pre-first-chunk 阶段提前 writeHead(200, text/event-stream)
  • 给 pre-first-chunk 失败保留正常 HTTP error 出口

回归测试

新增覆盖:

  • tests/stream-midflight-stall.unit.test.js

新增场景验证:

  1. responses 流在首个 chunk 之前失败时,不会提前写 SSE 头
  2. 之后由 handleError() 可以正确返回:
    • 429
    • application/json
    • 标准 error payload

执行结果:

  • node node_modules/jest/bin/jest.js tests/stream-midflight-stall.unit.test.js --runInBand --silent
  • 结果:11 passed

live 验证

验证 1:直接探针

请求:

  • POST http://127.0.0.1:3301/openai/v1/responses
  • body:
    • model: gpt-5.3-codex
    • stream: true

修复后实测结果:

  • STATUS 500
  • Content-Type: application/json
  • body:
{"error":{"type":"server_error","message":"[API Service] No healthy provider found in pool for openai-codex-oauth supporting model: gpt-5.3-codex", ...}}

这说明本地 gateway 已经不再伪装成一个错误的 SSE 流。

验证 2:debug store

GET /openai/debug/requests?limit=5 现在记录为:

  • statusCode: 500
  • finalStatus: failed

不再是之前那种:

  • statusCode: 200
  • 但 body 实际是 error JSON / stream contract 错配

验证 3:日志

logs/app-2026-04-28.log 中现在可以直接看到:

  • status=500
  • No healthy provider found in pool for openai-codex-oauth supporting model: gpt-5.3-codex

说明错误已经被收敛成正常失败,而不是假流式完成。

当前剩余问题(已和本次根因分离)

修掉这条本地 stream contract bug 之后,剩余问题已经清楚分层:

  1. openai-codex-oauth 当前本地 credential 仍会命中 429 usage_limit_reached
  2. 因为 runtime-overrides.json 已去掉 openai-codex-oauth -> openaiResponses-custom fallback,所以请求现在会 fail-fast 为正常 500
  3. codex exec 仍然可能持续较久才退出,这一段从 debug/log 看已经不是 Token Pool 挂流,而是 Codex CLI 收到连续失败后的客户端侧重试/退避行为

换句话说:

  • “本地 gateway 把错误伪装成断流”的 bug:fixed
  • “本地 codex oauth credential 没额度”:unfixed
  • “Codex CLI 在失败后会继续自己重试一段时间”:client-side behavior observed

第二阶段涉及文件

  • E:\My Project\Token Pool\src\utils\common.js
  • E:\My Project\Token Pool\tests\stream-midflight-stall.unit.test.js
  • E:\My Project\Token Pool\logs\app-2026-04-28.log

第二阶段状态

  • deploy decision: deployed(已重启本地 live worker)
  • live verification: passed(pre-first-chunk 失败已返回真实 500 application/json
  • root-cause status: confirmed and fixed
  • remaining follow-up:
    • 修通 openai-codex-oauth 可用上游 credential,或恢复一个已验证健康的 fallback
    • 若仍要继续追 <turn_aborted>,下一步应转到 Codex CLI/TUI 的失败后重试与 interrupt 链,不再是这条 gateway stream bug

第三阶段追加记录:Codex fallback 已接通,本地 codex exec 已真实返回 pong(2026-04-28 17:07 +08:00)

追加结论

第二阶段之后,剩余主链问题已经不是 stream contract,而是 本地 openai-codex-oauth 虽然仍会 429,但没有一个真实可用的本地 fallback credential 接上来

本次继续确认出的确定性根因有两条:

  1. configs/provider_pools.jsonopenaiResponses-custom 绑定的是错误的上游 key,不是当前 Codex CLI 真正在用的 live public key。
  2. configs/config.jsonconfigs/runtime-overrides.json 里都去掉了 openai-codex-oauth -> openaiResponses-custom 的 fallback 链,所以即便 openaiResponses-custom 本身可用,Codex 模型请求也接不过去。

修正这两点后:

  • openai-codex-oauth 命中 429 时,会正常切到 openaiResponses-custom
  • 本地 codex exec 已经通过 http://127.0.0.1:3301/openai/v1 真实返回 pong

单一路径追踪

1. live 上游并不是整体不可用

用当前 Codex CLI 正在使用的 live auth key 直接请求:

  • POST https://key.tengokukk.com/openai/v1/responses
  • model = gpt-5.3-codex

可以稳定拿到 200 completed

这说明:

  • 上游 key.tengokukk.com/openai/v1/responsesgpt-5.3-codex 是健康的
  • 问题不在公网 gateway 整体
  • 问题在本地 openaiResponses-custom 绑定错 key

2. 本地错误 key 导致 fallback 名义存在也不可用

configs/provider_pools.json 中原先的 openaiResponses-custom.OPENAI_API_KEY 不是 C:\Users\ASUS-KL\.codex\auth.json 中的 live key。

所以即使请求理论上应该 fallback 到 openaiResponses-custom

  • 实际也会打到错误凭证
  • 仍然无法形成可用接力

3. Codex 模型请求仍然先锚定到 openai-codex-oauth

src/services/service-manager.js 中的路由逻辑会把:

  • gpt-5.3-codex

先归一锚定到:

  • openai-codex-oauth

所以这条主路径真正要修通的不是“彻底绕开 codex family”,而是:

  • 允许 openai-codex-oauth 在 429/不可用时
  • 通过协议兼容链切到 openaiResponses-custom

4. 协议兼容本来就是允许的

src/providers/provider-pool-manager.jsareProviderProtocolsCompatible() 已明确允许:

  • codex
  • openaiResponses

之间互为兼容协议。

因此这次不需要加新代码分支,只需要恢复被去掉的 fallback 配置,并把 openaiResponses-custom 绑到正确 key。

本次修复

A. 恢复本地 codex fallback 链

更新:

  • E:\My Project\Token Pool\configs\config.json
  • E:\My Project\Token Pool\configs\runtime-overrides.json

补回:

  • openai-codex-oauth -> openaiResponses-custom

B. 把 openaiResponses-custom 绑定到当前 live Codex key

更新:

  • E:\My Project\Token Pool\configs\provider_pools.json

做法:

  • C:\Users\ASUS-KL\.codex\auth.json 读取当前 live OPENAI_API_KEY
  • 回填到本地 openaiResponses-custom.OPENAI_API_KEY

没有把 secret 写进文档或命令输出。

live 验证

验证 1:本地 /openai/v1/responses 已恢复成功

请求:

  • POST http://127.0.0.1:3301/openai/v1/responses
  • model = gpt-5.3-codex

结果:

  • statusCode = 200

/openai/debug/requestslogs/app-2026-04-28.log 显示:

  • 先尝试 openai-codex-oauth
  • 命中 429
  • 然后 Fallback activated (Chain): openai-codex-oauth -> openaiResponses-custom
  • 最终 status=200

这说明 fallback 主链已经真实接通。

验证 2:真实 codex exec 已返回 pong

执行:

  • codex exec --ephemeral --skip-git-repo-check --json -c 'model_provider="crs"' -c 'model_providers.crs.base_url="http://127.0.0.1:3301/openai/v1"' -C 'E:\My Project\Token Pool' '你好,只回复pong'

实测结果:

  • 退出码:0
  • 事件流最后一条 agent message:pong

这说明用户要的主路径:

  • Codex CLI -> 本地 Token Pool -> fallback -> 上游 responses

已经恢复可用。

继续排查:为什么 gateway 已经正常 500,Codex CLI 还是要拖很久才退

复现方式

为了构造一个确定性的快速失败路径,执行:

  • codex exec ... -c 'model="gpt-4o-mini"' ...

当前本地 openaiResponses-custom 不支持 gpt-4o-mini,所以 gateway 会立即返回正常 500

现场证据

  1. codex exec 总耗时:
    • 35.97s
  2. codex exec JSONL 输出:
    • 连续出现 Reconnecting... 1/5
    • 一直到 Reconnecting... 5/5
    • 最终 turn.failed
  3. 本地 gateway 日志:
    • 2026-04-28T09:06:30.815Z2026-04-28T09:06:55.389Z
    • 连续收到 30POST /openai/v1/responses
    • 每次都在约几毫秒内结束为正常 500

工程级结论

这说明“gateway 已经正常失败,但 Codex 还拖很久才退出”的根因不在 gateway 卡住,而在 Codex CLI 自己的 reconnect / retry backoff 机制

  • 本地 gateway 没有悬挂
  • 本地 gateway 没有假流
  • 本地 gateway 每次都快速明确返回 500
  • 但 Codex CLI 仍会把这类失败解释成“可重连/可重试”的暂时性错误,并继续进行 5 轮 reconnect

也就是说,这段长尾时间是:

  • client-side reconnect budget

不是:

  • gateway-side hang

第三阶段涉及文件

  • E:\My Project\Token Pool\configs\config.json
  • E:\My Project\Token Pool\configs\runtime-overrides.json
  • E:\My Project\Token Pool\configs\provider_pools.json
  • E:\My Project\Token Pool\logs\app-2026-04-28.log

第三阶段状态

  • deploy decision: deployed(已重启本地 live worker)
  • live verification: passed(本地 codex exec 已真实返回 pong
  • root-cause status:
    • openaiResponses-custom 错绑上游 key:confirmed and fixed
    • openai-codex-oauth -> openaiResponses-custom fallback 缺失:confirmed and fixed
    • Codex CLI 慢退出:confirmed as client-side reconnect behavior
  • remaining follow-up:
    • 若要继续缩短失败退出时间,下一步应转向 Codex CLI / provider adapter 如何映射 500 为“可重连错误”的判定逻辑,而不是继续改 Token Pool 的流收尾

第四阶段追加记录:Codex CLI 为什么会把本地快速 500 判成 Reconnecting... 1/5(2026-04-28 17:25 +08:00)

追加结论

这一步已经确认:

  • 本地 Token Pool 对失败请求并没有挂住
  • 也没有继续伪装 stream 完成
  • 它是在几毫秒内稳定返回正常 500

但 Codex CLI 仍然会把这类失败当作 turn-level reconnect / retry,于是用户看到:

  • Reconnecting... 1/5
  • 一直到 Reconnecting... 5/5

这条慢退出链的主因已经可以定性为:

  • Codex CLI 内部把 /responses 的非完成错误当成可重试的 turn stream failure

而不是:

  • gateway hang
  • gateway 假断流
  • 本地 wrapper 注入 <turn_aborted>

本地确定性证据

1. 失败复现

执行:

  • codex exec ... -c 'model="gpt-4o-mini"' ...

当前本地 openaiResponses-custom 不支持 gpt-4o-mini,所以这是一个稳定的“立刻失败”探针。

2. CLI 表面行为

本地 JSONL 输出稳定出现:

  • Reconnecting... 1/5
  • Reconnecting... 2/5
  • Reconnecting... 3/5
  • Reconnecting... 4/5
  • Reconnecting... 5/5
  • 最终 turn.failed

3. Gateway 实际行为

同一时间窗内,本地 GET /openai/debug/requests?limit=80 显示:

  • 共记录到 30POST /openai/v1/responses
  • 时间窗约 25s
  • 每次都是正常 500
  • 没有任何一条请求卡住等待完成

也就是说:

  • 从 gateway 视角看,这是快速失败风暴
  • 从 CLI 视角看,它把这风暴包装成 reconnect 链

来自 OpenAI 官方 openai/codex 的补充证据

官方 issue 中已经能看到同类模式:

  1. VScode插件Codex在wsl中Ubuntu系统超时: Reconnecting...
    issue #8814
    日志显示:

    • codex_core::codex: stream disconnected - retrying turn (1/5 in 207ms)...
    • 错误源是 codex_api::endpoint::responses
  2. stream error: unexpected status 404 Not Found
    issue #2851
    即使是 404,CLI 也会显示:

    • retrying 1/5
    • 一直到 retrying 5/5

这两条证据说明:

  • CLI 的 retry 不是只针对“纯网络断连”
  • unexpected status 404 这类明确 HTTP 错误,也会先进 turn retry 流程

因此本地看到:

  • gateway 已快速 500
  • CLI 仍然 Reconnecting... 1/5

在行为上和官方已知模式是一致的。

工程级判断

从当前证据看,Codex CLI 的内部判定更接近:

  • “本轮 /responses 没有正常 completed”

而不是:

  • “HTTP 已明确失败,所以立即终止且不重试”

换句话说,它更像是:

  • turn stream failure classifier

而不是简单:

  • HTTP status classifier

所以只要一轮 /responses 没能以它期望的完成路径结束,就可能走:

  • retry/backoff
  • reconnect UI
  • 最终 turn.failed

对本地 Token Pool 的含义

这意味着:

  1. 你前面修的 gateway stream contract 是必要的
    否则 CLI 会在错误输入上更加混乱。

  2. 但即使 gateway 现在已经“正常失败”,CLI 仍然可能继续重试
    这是 client-side policy,不是 gateway-side hang。

  3. 如果以后要继续缩短失败退出时间,主战场不再是:

    • src/utils/common.js
    • gateway stream 收尾

而是:

  • Codex CLI / upstream adapter 对 /responses 错误的重试判定

第四阶段状态

  • deploy decision: not-needed(本阶段无新的 runtime patch)
  • live verification: passed(CLI reconnect 行为已稳定复现并与 gateway 快速 500 对齐)
  • root-cause status:
    • CLI 慢退出的 gateway-side hang 假设:ruled out
    • CLI 将失败 turn 归类为 reconnect/retry:confirmed with local evidence + official issue evidence
  • remaining follow-up:
    • 若要进一步压缩失败退出时间,应继续追 Codex CLI 对 /responses 失败的 retry classifier,而不是继续在 Token Pool 内追加 fallback/timeout 修补

第五阶段追加记录:Codex CLI 对 /responses 失败形态的真实分类矩阵

本阶段目标

把“gateway 已经快速正常失败,为什么 Codex CLI 还会拖很久才退并显示 Reconnecting... 1/5”继续收敛到具体响应形态级别,而不是停留在“可能是客户端策略”这一级。

1. 先钉死本地 gateway 的真实失败响应

对当前稳定失败探针:

  • POST http://127.0.0.1:3301/openai/v1/responses
  • model = gpt-4o-mini
  • Authorization = Bearer sk-tengokukk-crs-badd296ef15f233a79ba0049

直接抓到的真实响应是:

  • HTTP/1.1 500 Internal Server Error
  • Content-Type: application/json
  • body:
{"error":{"type":"server_error","message":"[API Service] No healthy provider found in pool for openaiResponses-custom supporting model: gpt-4o-mini","code":"server_error"}}

这一步排除了一个常见误判:

  • 这里不是 gateway 卡住不回
  • 也不是 gateway 偷偷保持长连接
  • 而是同步、明确、快速地返回了一个普通 JSON 500

2. 用可控 mock 逐个比较 Codex CLI 的反应

为了把 CLI 分类逻辑继续收窄,额外做了一个本地最小 mock /openai/v1 服务:

  • 文件:C:\Users\ASUS-KL\AppData\Local\Temp\codex-mock-responses.js
  • 通过 codex exec --json ... -c 'model_providers.crs.base_url="http://127.0.0.1:3399/openai/v1"' 直接打这个 mock

重点不是 mock 自己长什么样,而是:同一条 Codex CLI 执行链,只改响应形态,其它条件不变

3. 实验矩阵

A. 纯 HTTP JSON 错误

  1. 400 + application/json + invalid_request_error

    • 结果:不重连
    • CLI 输出:直接 turn.failed
    • 耗时:约 11.47s
  2. 429 + application/json + rate_limit_error

    • 结果:不显示 Reconnecting
    • CLI 输出:exceeded retry limit, last status: 429 Too Many Requests
    • 耗时:约 11.49s
  3. 401 + application/json + authentication_error

    • 结果:重连 5 次
    • CLI 输出:unexpected status 401 Unauthorized: ...
    • 耗时:约 18.02s
  4. 404 + application/json + invalid_request_error

    • 结果:重连 5 次
    • CLI 输出:unexpected status 404 Not Found: ...
    • 耗时:约 17.94s
  5. 500 + application/json + server_error

    • 结果:重连 5 次
    • CLI 输出:We're currently experiencing high demand, which may cause temporary errors.
    • 耗时:约 37.26s

B. 200,但不是 response.completed 终止

  1. 200 + 普通 JSON completed body(非 SSE)

    • 结果:重连 5 次
    • CLI 输出:stream disconnected before completion: stream closed before response.completed
  2. 200 + SSE error event

    • 结果:重连 5 次
    • CLI 输出:同样是 stream closed before response.completed
  3. 200 + SSE response.failed

    • 结果:重连 5 次
    • CLI 输出:stream disconnected before completion: mock sse_failed_event_200

这三条一起说明:

  • 对 Codex CLI 来说,/responses 这条流式链如果没有落到它认可的 response.completed 终止路径,就会被归类成“未完成断流”
  • 即使 HTTP 是 200
  • 即使已经显式发了 errorresponse.failed
  • 也还是会触发 turn retry / reconnect

C. 200 + response.completed

  1. 200 + SSE response.completed + response.status = completed

    • 结果:不重连
    • CLI 输出:turn.completed
    • exit code: 0
  2. 200 + SSE response.completed + response.status = failed + error

    • 结果:也不重连
    • CLI 输出:仍然是 turn.completed
    • exit code: 0

这一条是本阶段最关键的新证据:

  • Codex CLI 把 response.completed 当成比 response.status / response.error 更高优先级的 turn 终止信号
  • 也就是说,只要走到了 response.completed,它就直接把这轮当成“已完成”
  • response.status = failed 并不会让 codex exec 变成失败退出

4. 由此得到的确定性结论

现在可以把这条链完整收敛成下面这个判断:

  1. 当前本地 gateway 对 unsupported model 的失败响应是:

    • 同步 JSON 500
    • 不是 hang
    • 不是假断流
  2. Codex CLI 对 /responses 的失败不是单纯按“HTTP 明确失败 -> 立刻退出”处理。

  3. 它内部至少存在两层更强的分类:

    • 协议终止层:有没有它认可的 response.completed
    • 状态码策略层:某些 HTTP 错误被当成 turn retry / reconnect
  4. 从本地实测矩阵看:

    • 400 / 429 会较快失败,不走 Reconnecting...
    • 401 / 404 / 500 会进 Reconnecting... 1/5
    • 200 但没有 response.completed 也会进 Reconnecting... 1/5
  5. 因此,当前这类“gateway 已经快速 500,但 CLI 仍然拖很久才退”的现象,根因已经不在 Token Pool:

    • 它是 Codex CLI 对 /responses 失败 turn 的内建 retry / reconnect classifier
    • 不再是 gateway stream contract、auth hang、provider fallback、或本地 wrapper 注入造成的

5. 对 gateway 还能不能继续“修掉它”的结论

这里也顺便得到一个非常重要的边界结论:

如果强行想让 Codex CLI 不重连,只剩两种方向:

  1. 返回 400 / 429 这类它会快速失败的状态码
  2. 返回 200 + response.completed

但第 2 条会把本来应该失败的 turn 伪装成成功完成,因为 codex exec 会直接 turn.completed 并以 exit code 0 退出。

所以:

  • 不能通过“更漂亮的 SSE 错误事件”同时获得“正确失败语义”与“零 reconnect”
  • 401 / 404 / 500 这种失败形态,CLI 的慢退出属于客户端策略,不是本地 gateway 还能继续靠协议补丁彻底消掉的问题

6. 本阶段结论

  • deploy decision: not-needed
  • live verification: passed
  • root-cause status:
    • gateway 快速失败但 CLI 仍慢退出:confirmed client-side
    • 触发条件已缩小到具体响应形态:confirmed
  • remaining follow-up:
    • 若以后还要进一步缩短这类失败的退出时间,主战场只能是 Codex CLI 自身的 /responses retry classifier,或者上游/路由层尽量避免把请求导向会产生 401 / 404 / 500 的失败路径