Skip to content

fix: 上游没正常走完 finish 时不再谎报成功(max_output_tokens / pause_turn / *-error / 无 finish 事件) - #39

Merged
MAXeaglet merged 2 commits into
MAXeaglet:masterfrom
xelr233:fix/issue-38-finish-reason
Sep 16, 2026
Merged

MAXeaglet merged 2 commits into
MAXeaglet:masterfrom
xelr233:fix/issue-38-finish-reason

Conversation

@xelr233

@xelr233 xelr233 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

修复 #38。

68664c2 修了 stop_reason 的一个成员('tool-calls' 连字符),方向完全正确 ——
但同一族里还有四个成员没处理,后果都比它更严重:上游明明截断了,下游收到的是「正常结束」。

现状

function mapFinishReason(reason) {
  switch (reason) {
    case 'tool-calls': return 'tool_calls';
    case 'length': return 'length';
    case 'stop': return 'stop';
    default: return reason || 'stop';       // ← 其余全部原样放行
  }
}
function mapAnthropicStopReason(finishReason) {
  ...
  default: return 'end_turn';               // ← 其余全部报成正常结束
}
情形 OpenAI 侧 Anthropic 侧 Responses 侧
max_output_tokens / model_context_window_exceeded 非法枚举 end_turn completed
pause_turn finish_reason:"pause_turn"(非法) end_turn completed
network-error 等 非法枚举 end_turn completed
无 finish 事件 直接 [DONE],连 finish_reason 块都没有 stopReason || 'end_turn' 无条件兜底 completed

CLI 侧的完整语义

// ① 截断类不止 'length'
function normalizeStopReason2(e){
  const t=(e??"").toLowerCase();
  return "tool_use"===t||"tool-calls"===t||"tool_calls"===t ? "tool_use"
       : "length"===t||"max_tokens"===t||"max_output_tokens"===t
         ||"model_context_window_exceeded"===t ? "max_tokens"
       : "end_turn"
}

// ② pause_turn 被续写循环吸收,永不外泄(Ph = 5)
for(let n=0;n<=Ph;n++){ ... if("pause_turn"!==o.rawFinishReason) break;
                        t.onContinuation?.({kind:"pause",attempt:n+1,maxAttempts:Ph}) }

// ③ provider 中途断掉
function isNetworkFailureFinish(e){ return void 0!==e && /^(?:network|connection|upstream)[-_\s]?error$/i.test(e.trim()) }
if (isNetworkFailureFinish(s)) throw transportError({ ..., status:502, retryable:!0 });

// ④ 没有 finish 事件(m 就是 sawFinish 标志位)
if(!m) throw transportError({ message:"Stream ended unexpectedly before completion (no finish event) — response was truncated",
                              status:502, retryable:!0 });

改法

  • 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])。
  • Responses 流式原先直接比对原始值 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 条。

node --test test/*.test.mjs   →  pass 15 / fail 0

只带这一组测试所需的脚手架:test/helpers.mjs 与 package.json 的 test 脚本。
helpers.mjs 与 PR #34 中的文件逐字节一致,两边先后合并都不会冲突
(git 对「两侧新增同一份相同内容」不视为冲突),所以本 PR 不依赖 #34。

复验

curl -sSL "$(curl -sS https://registry.npmjs.org/command-code/latest \
  | grep -o 'https://registry.npmjs.org/command-code/-/command-code-[^"]*\.tgz' | head -1)" \
  | tar xzO package/dist/cli.mjs > cli.mjs

grep -o 'function normalizeStopReason2([^}]*}' cli.mjs
grep -o 'function isNetworkFailureFinish([^}]*}'  cli.mjs
grep -o 'Stream ended unexpectedly before completion[^"]*' cli.mjs
grep -o 'Ph=[0-9]*' cli.mjs

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
MAXeaglet merged commit 9bdfafc into MAXeaglet:master Sep 16, 2026
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 行同步修正
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants