概述
#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 仍全绿。)
概述
#54(commit2eccdbf)把/v1/responses流式改成「上游一 200 就立刻发response.created/response.in_progress」:而翻译器暴露的是
于是零输出防护
成了死代码(
translator.started恒为 true)。实测差异(同一 mock 上游:
{"type":"start"}+{"type":"finish","finishReason":"stop","totalUsage":{"inputTokens":5,"outputTokens":0}})cce214d(旧){"error":{"message":"Empty response from upstream (zero output tokens)","type":"rate_limit_error"},"retry_after":10}ce5a217(新)"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事件(实测):所以建议按本文件既有的失败口径走
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 test41/41 仍全绿。)