Skip to content

[Bug] /v1/responses 流式零输出防护失效(#54 回归):空响应被谎报为 response.completed,旧版是 429 #56

Description

@lilyblessing

概述

#54(commit 2eccdbf)把 /v1/responses 流式改成「上游一 200 就立刻发 response.created / response.in_progress」:

// proxy.mjs:3399(ce5a217)
await writeEvents(translator.start());   // → startResponse() 里 createdSent = true

而翻译器暴露的是

// proxy.mjs:3166
get started() { return createdSent; },

于是零输出防护

// proxy.mjs:3450(ce5a217)
} else if (translator.outputTokens === 0 && !translator.started) {
  sendResponsesError(res, 429, 'rate_limit_error', 'Empty response from upstream (zero output tokens)', 10);

成了死代码(translator.started 恒为 true)。

实测差异(同一 mock 上游:{"type":"start"} + {"type":"finish","finishReason":"stop","totalUsage":{"inputTokens":5,"outputTokens":0}})

版本 HTTP 状态 终局事件
cce214d(旧) 429 {"error":{"message":"Empty response from upstream (zero output tokens)","type":"rate_limit_error"},"retry_after":10}
ce5a217(新) 200 SSE "status":"completed","output":[],"output_text":"","usage":{"output_tokens":0,...}

这与 README_zh.md:465「零输出防护 outputTokens=0 → 429」的承诺矛盾,本质是把空响应谎报成功 —— 和 #38 / #39 / PR #39 修掉的「静默截断谎报成功」是同一类问题,只是这条路径漏了。

修复时的一个坑

因为 created 已提前发出,响应头已经提交为 200,状态码不可能再改回 429 —— 直接沿用原来的 sendResponsesError(res, 429, ...) 只会抛
Cannot write headers after they are sent to the client,客户端收到的是一个内部 error 事件(实测):

event: response.created
event: response.in_progress
event: error
data: {"type":"error","sequence_number":2,"code":null,"message":"Cannot write headers after they are sent to the client",...}

所以建议按本文件既有的失败口径走 response.failed(finish() 的 incomplete 分支、以及 L3398 的注释「上游随后失败会走 response.failed」都是这个口径):

  • 新增一条独立判据(例如 get hasOutput() { return outputIndex > 0 || doneItems.length > 0; },即「是否真的产出过 output item」)替代 !translator.started;
  • 命中时 if (!started) → 429(保留旧路径的语义),否则 translator.fail('Empty response from upstream (zero output tokens)') 写 response.failed;
  • ⚠️ 该分支 return 会跳过流式分支尾部的 if (!res.writableEnded) res.end();,必须就地 res.end(),否则客户端会挂在一个永不结束的 SSE 响应上(本地实测会挂到挂起保护才退)。

(我按上述方式在本机打了本地补丁:同场景现在返回 200 + response.failed(status:"failed"、error.code:"upstream_error"),无 response.completed、无内部错误事件、正常收尾;npm test 41/41 仍全绿。)

Activity

  1. xelr233 commented on Oct 2, 2026

    @xelr233
    Contributor

    已确认并在 #57 提交修复,感谢这份如此详尽的报告 —— 根因定位、ERR_HTTP_HEADERS_SENT 的坑、res.end() 漏收尾三个点全部命中,修复方案就是按你给出的口径实现的。

    复核补充两点:

    1. 根因确认:#54 之后 translator.start() 在收到上游 200 时被无条件提前调用(治首字前 15~40s 静默被 nginx/CDN 掐连接),createdSent 自此恒为 true,「outputTokens === 0 && !translator.started」成了死代码。同一 mock 场景下实测与你的表格一致:cce214d 返回 429,ce5a217 起返回 200 + response.completed(output: [])谎报成功。

    2. 修复口径(fix: 流式 /v1/responses 零输出防护失效(#56,#54 回归)—— 空响应不再谎报 response.completed #57):

      • 判据换成新增的 get hasOutput()(outputIndex > 0 || doneItems.length > 0,即「是否真的产出过 output item」),替代恒真的 !translator.started;
      • 命中且响应头已提交时不再走 sendResponsesError(会抛 ERR_HTTP_HEADERS_SENT),按本文件既有失败口径 translator.fail(...) 写 response.failed(status:"failed"、error.code:"upstream_error"、message 沿用 Empty response from upstream (zero output tokens));!started → 429 子分支保留兜底;
      • 该分支就地 res.end() —— 你指出的「return 会跳过流式分支尾部收尾、客户端挂在永不结束的 SSE 上」已按此处理;
      • 非流式路径未被 #54 波及(按 fullText/thinkingText/toolCalls 判空),保持 429 不动。

    验证:新增 test/responses-zero-output.test.mjs 三个用例(流式空响应 → response.failed 且无 response.completed、正常输出不受影响、非流式仍 429),并在修复前的代码上做了反向验证 —— 用例如预期红,证明能抓住这个回归;相邻套件 36/36 全绿。全量矩阵(node 22 / node 24 / bun)在 #57 的 CI 已通过。

    另外你的本机补丁与 #57 行为一致(200 + response.failed、无内部错误事件、正常收尾),可以直接沿用,也可以等 #57 合并后同步。

  2. added a commit that references this issue on Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions