Repository navigation
fix: 上游没正常走完 finish 时不再谎报成功(max_output_tokens / pause_turn / *-error / 无 finish 事件) - #39
Merged
Merged
Conversation
68664c2 修了 stop_reason 的一个成员('tool-calls' 连字符),方向正确, 但同一族里还有四个成员没处理,后果都比它更严重:**上游明明截断了, 下游收到的是「正常结束」**。对照 command-code@1.54.0 dist/cli.mjs 逐条对齐。 四种情形(mapFinishReason 只认 tool-calls/length/stop,其余原样放行): 1) max_output_tokens / model_context_window_exceeded CLI 的 normalizeStopReason2 把这两个都算 max_tokens。原实现走 default: OpenAI 侧透出非法枚举,Anthropic 侧 mapAnthropicStopReason 兜底成 end_turn —— 上下文撑爆被报成正常结束。 2) pause_turn Anthropic 原生枚举,表示「这一轮被暂停,后面还有」。CLI 靠自动续写循环 (Ph=5)把它吸收掉,代理不续写就必须如实上报,不能吞。 Anthropic 侧原样透出 pause_turn;OpenAI 没有对应枚举,折成 length (表达「输出不完整」)而不是折成 stop(那是谎报完成)。 3) network-error / connection-error / upstream-error CLI 的 isNetworkFailureFinish → 502 可重试。 4) 流里根本没有 finish 事件 CLI:"Stream ended unexpectedly before completion (no finish event) — response was truncated" → 502 可重试。 原实现 Anthropic 侧 `stopReason || 'end_turn'` 无条件兜底,OpenAI 侧 连 finish_reason 块都不发直接 [DONE]。 修法: - mapFinishReason 全量归一化(length 家族 / upstream_error),未知值原样返回, 不再静默折成 stop - mapAnthropicStopReason 增加 pause_turn / refusal 原样透出 - 新增 toOpenAIFinishReason:pause_turn → length - 新增 incompleteUpstreamDetail(sawFinish, finishReason) / incompleteUpstreamError(), 三条协议共用一个判定 - 六处补 sawFinish 跟踪(OpenAI 流式/非流式、Anthropic 流式/非流式、Responses 流式/非流式), 没走完 finish 时:非流式报 502 可重试,流式发 error 事件而不是补一个假的结束标志 - Responses 流式原先直接比对原始 finishReason === 'length',改为用归一化后的值 - 流式路径把「没有正常结束」判定排在「零输出」之前 —— 上游压根没发 finish 时, 「no finish event」才是根因,按 429 报会掩盖它 sawFinish 的口径是「上游给过任何完成信号」:finish 与代理一直在处理的 finish-step 都算。(finish-step 不在 CLI 的事件集里,但既然代理认它,就不能让它变成「没完成」, 否则会把原本正常的响应误判成 502。真正要拦的是「一个完成信号都没有就断了」。) 测试:新增 test/stream-end.test.mjs 15 条(三种协议 × 四种情形 + 正常结束不受影响的回归), 全套 73 → 88 全绿。
本 PR 只带这一组测试所需的脚手架:test/helpers.mjs 与 package.json 的 test 脚本。 helpers.mjs 与 PR MAXeaglet#34 中的文件逐字节一致,两边先后合并都不会冲突(git 对 「两侧新增同一份相同内容」不视为冲突)。 15 条断言 = 三种协议(chat / messages / responses)× 四种情形(截断类 finishReason、 pause_turn、provider 连接失败、无 finish 事件),外加两条「正常结束不受影响」的回归。
MAXeaglet
pushed a commit
that referenced
this pull request
Sep 16, 2026
…AliveTimeout (#43) * test: 最小测试脚手架(helpers + npm test) 本 PR 只带这一组测试所需的脚手架:test/helpers.mjs 与 package.json 的 test 脚本。 helpers.mjs 与 PR #34 / #39 中的文件**逐字节一致**(blob 3342bb8), 所以三份先后合并都不会冲突 —— git 对「两侧新增同一路径且内容相同」不视为冲突。 * fix: 采纳上游 error 事件自带的 statusCode,并补齐无内容事件的静默列表 线上故障排查中发现的两个可观测性问题。 【一】mapCcEventError 丢掉 error.statusCode CLI 的 readStreamErrorEvent 读的就是这个字段,取值链是 parseEmbeddedErrorJSON(message)?.status ?? error.statusCode ?? null 原实现只看 message 里的 "<NNN>" 前缀,statusCode 一律被丢掉 → 一律塌成 502。 后果:上游报 429/503(限流、容量)时我们回 502 upstream_error —— 客户端不按限流退避,监控也把它错归类成后端故障。 线上那条 "The request limited providers for this model and they are currently at capacity..." 就可能因此被记成 502 而不是 429 (按 CLI 的 isStreamErrorRetryable,该消息不含任何 terminal 标记 premium_credits_exhausted / model_not_in_plan / insufficient credits, 所以它是可重试的)。 改法:采纳 error.statusCode("<NNN>" 前缀仍优先,与 CLI 一致), 返回值增加 reportedStatus 以区分"上游报的"与"我们映射后的"; 四个 CC error 日志点改为先映射再记日志,并打出 upstreamStatus / upstreamRetryable / code / mappedTo —— 与作者 78353d9 对 mapCcError 的处理保持一致。 【二】无内容事件的静默列表不全 上游每个响应都会发一串不携带内容的事件(text-start / text-end / start / start-step / reasoning-start / reasoning-end / finish-step / provider-metadata / tool-input-start|delta|end / tool-error)。三条非流式路径里: · OpenAI / Anthropic 非流式:缺 text-start / start / start-step / reasoning-start / finish-step · Responses 非流式:**一个静默列表都没有** 于是 journalctl 被 Unknown CC event type 刷屏,真正的错误被淹没。 改法:三处统一成同一份静默列表;default 仍保留警告,真正没见过的类型照旧留痕。 测试:新增 test/connection-lifecycle.test.mjs 5 条(statusCode 映射 4 条 + 三协议 × 流式/非流式不产生噪音 1 条)。 * fix: 流空闲超时改用 res.end() 收尾 —— res.destroy() 会丢缓冲并发 RST 三处流式超时路径(OpenAI / Anthropic / Responses)原先都是同一个模式: res.write(\`data: \${JSON.stringify({ error: ... })}\\n\\n\`); res.destroy(); res.write() 是异步的,紧接着 destroy() 会把尚未刷出的缓冲丢掉并发 RST。 反向代理侧看到的就是"上游连接被重置": 响应头尚未转发到客户端 → 502 Bad Gateway 已转发 → 客户端 connection error / 截断的流 也就是说:代理本来是"主动截断并告知错误",实际却变成了"把客户端连接搞断"。 下游 SDK 本来能把 rate_limit_error 当可重试错误处理,现在只能吃一个连接层异常。 线上现场(1c2g + OpenResty 反代,资源指标全部健康:NRestarts=0、 MemoryCurrent=215MB、LimitNOFILE=524288、CPU 1.5%、无 OOM): 反代 error.log: sendfile() failed (32: Broken pipe) while sending request to upstream upstream timed out (110) while connecting to upstream 代理 journal: Stream idle timeout {elapsedMs:147005, bytesReceived:833893, lastCcEvent:"reasoning-delta"} (833KB/147s ≈ 5.5KB/s —— 上游本来就慢,30s 空闲阈值确实会被触发; 问题不在"超时",在超时之后怎么收尾) 改法:三处改为 res.end(errEvent) —— 把错误事件正常写进 SSE 流再发 FIN, 下游按可重试错误处理。下游若已僵死(不读也不断),仍由 CLIENT_DRAIN_TIMEOUT_MS 那条路径强制断开,职责不变(那里的 destroy 故意保留)。 测试:新增 1 条,并**验证过有区分度** —— 把 end() 换回 destroy() 时该用例失败, 客户端拿到 "TypeError: terminated"(连接被重置);换回 end() 通过。 用例自带一个"发一半就挂住"的上游,用 fetch().text() 是否成功即可判别两种收尾方式。 * fix: 显式设置 server.keepAliveTimeout,消除反代复用已关闭连接的 EPIPE proxy.mjs 从未设置过 server.keepAliveTimeout,等于把「反代空闲超时 vs 后端空闲超时」 的时序完全交给 Node 默认值(5s)与反代配置的巧合。而这个项目的部署形态是已知的 (README 里就是 nginx / OpenResty 反代),不该靠巧合。 规则:反代的 upstream keepalive_timeout 必须**小于**后端的 keepAliveTimeout。 一旦反过来的,反代会从缓存里取出一条后端已关闭的连接,把请求体写过去 → EPIPE, 而 POST 是非幂等、nginx 默认不重试 → 客户端直接吃 502。线上 error.log 里那 8 条 sendfile() failed (32: Broken pipe) while sending request to upstream 就是这一类。注意是 sendfile() 而非 writev(),说明这些请求体大到被反代缓冲落盘。 Node 默认 5s 与反代常见的 4s 只差 1 秒余量;而两边的计时基准本就不同 (反代从"读完响应放回缓存"起算,后端从"写完响应"起算)。大响应体(线上是 600~830KB 的流式响应)下这点余量随时会被吃掉。 改法:显式 server.keepAliveTimeout = 65s、server.headersTimeout = 66s (CC_KEEPALIVE_TIMEOUT_MS 可覆盖),与 Node 官方"部署在反向代理之后"的建议一致 (keepAliveTimeout > 前端 idle timeout)。启动横幅打出该值并提示反代侧的对应项, 便于部署方对齐。 测试:新增 1 条,锁定"启动横幅必须打出 keepAliveTimeout 且提示反代对应项"。
MAXeaglet
pushed a commit
that referenced
this pull request
Oct 9, 2026
#54 把 response.created 提前到上游一返回 200 就发(治首字前 15~40s 静默期被 nginx/CDN 掐连接),translator.started(=createdSent)自此恒为 true, 「outputTokens===0 && !translator.started」成了死代码:空响应经 finish() 包装成 response.completed 谎报成功(旧版 cce214d 是 429 rate_limit_error), 与 #38/#39 修掉的「静默截断谎报成功」同类。 - 判据换成 hasOutput(是否真的产出过 output item:outputIndex>0 || doneItems>0) - 命中时若响应头已提交(常态),不能再 sendResponsesError —— 会抛 ERR_HTTP_HEADERS_SENT;按本文件既有失败口径 translator.fail → response.failed (status:"failed"、error.code:"upstream_error"、message 说明空响应) - 该分支就地 res.end():return 会跳过流式分支尾部的 res.end(), 漏掉客户端会挂在永不结束的 SSE 上 - 非流式路径未被 #54 波及(按 fullText/thinkingText/toolCalls 判空),仍是 429 - 新增 test/responses-zero-output.test.mjs(修复前跑它如预期红,修复后绿); README 两个语言的零输出防护/429 行同步修正
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
修复 #38。
68664c2修了stop_reason的一个成员('tool-calls'连字符),方向完全正确 ——但同一族里还有四个成员没处理,后果都比它更严重:上游明明截断了,下游收到的是「正常结束」。
现状
max_output_tokens/model_context_window_exceededend_turncompletedpause_turnfinish_reason:"pause_turn"(非法)end_turncompletednetwork-error等end_turncompletedfinish事件[DONE],连 finish_reason 块都没有stopReason || 'end_turn'无条件兜底completedCLI 侧的完整语义
改法
mapFinishReason全量归一化:length家族(含max_output_tokens/model_context_window_exceeded)→length;network|connection|upstream[-_ ]error→upstream_error。未知值原样返回,不再静默折成stop。mapAnthropicStopReason增加pause_turn/refusal原样透出:pause_turn是Anthropic 的合法枚举,表示「这一轮被暂停,后面还有」,CLI 是靠自动续写把它吸收掉的;
代理不自动续写,就必须如实上报。
toOpenAIFinishReason:pause_turn→length(OpenAI 没有对应枚举,折成
length至少有「输出不完整」的含义,折成stop就是谎报完成)。incompleteUpstreamDetail(sawFinish, finishReason)与incompleteUpstreamError(),三条协议共用一套判定。
sawFinish跟踪:没走完 finish 时,非流式报 502 可重试,流式发 error 事件而不是补一个假的结束标志(
message_stop/[DONE])。finishReason === 'length',改为用归一化后的值 ——否则
max_output_tokens会漏判成completed。finish时,「no finish event」才是根因,按 429 报会把真正的原因盖掉。
关于
sawFinish的口径判定的是「上游给过任何完成信号」:终态
finish,以及本代理一直在处理的finish-step。(
finish-step并不在 CLI 的事件集里 ——dist/cli.mjs中 0 处出现 —— 但既然代理认它,就不能让它变成「没完成」,否则会把原本正常的响应误判成 502。真正要拦的是
「一个完成信号都没有就断了」。)
没有做自动续写
CLI 遇到
pause_turn会重发同一个请求体并拼接 content / usage,最多 6 次。本 PR 没有照做,理由是:续写语义依赖服务端行为(重发是否幂等、返回的是续接内容还是从头再来),
在没有真实 key 的情况下无法验证;而「如实上报
pause_turn」不会丢内容 ——下游(尤其是 Anthropic 原生 SDK)本来就知道该怎么续。
如果确实要做续写,那应该是另一个 PR,并且需要先确认服务端语义。
测试
新增
test/stream-end.test.mjs:三种协议 × 四种情形 = 12 条,外加 3 条回归(正常
stop仍照常完成、tool-calls仍报tool_use/tool_calls、max_output_tokens在 Responses 上报
incomplete),共 15 条。只带这一组测试所需的脚手架:
test/helpers.mjs与package.json的test脚本。helpers.mjs与 PR #34 中的文件逐字节一致,两边先后合并都不会冲突(git 对「两侧新增同一份相同内容」不视为冲突),所以本 PR 不依赖 #34。
复验