Skip to content

v3.9.2

Choose a tag to compare

@github-actions github-actions released this 28 Jul 12:25

v3.9.2

v3.9.1 的补丁版。发版后做了一轮对抗复核,查出 3 条缺陷 —— 全部是 v3.9.1 自己的
修复引入的
,其中 1 条 blocker 让 v3.9.1 的主打修复在默认后端上完全失效。

建议从 v3.9.1 升级。升级无需改配置。


用户可感知

(blocker) 流式截断守卫在默认后端上被绕过

v3.9.1 声称修好了"流式回复中途断开被报成完成"。实际只在 connect 路径修好了。

Cascade 流在已经发出内容之后中途死掉时,代理会补发一个合成的
finish_reason:'stop' 收尾 —— 这个 wire 形态本身是对的(把错误当 content delta
注入会污染 assistant 消息)。但 v3.9.1 的守卫问的是"有没有收到终止 chunk",
而合成的那一帧正好满足它。

Cascade 是默认后端,所以对多数部署来说,v3.9.1 的这条修复等于没生效:半截
回复照旧报成 response.completed,并写进 response store 成为下一轮上下文。

复核用三种真实错误形态实测复现:HTTP/2 pending stream has been canceled
provider context deadline exceededECONNRESET。带 tool_call 的变体还会在 store
里留下一条永远不会有结果的 tool_calls

现在合成帧带内部标记,translator 不再把它计为终止 chunk。标记只在内部
translator 路由
(messages / gemini / responses)发出 —— 直连
/v1/chat/completions 的客户端看到的 wire 逐字节不变。

合法截断的一轮重新可以链式续接

v3.9.1 把 store 提交门写成了"非 truncated",于是 length / content_filter
这种合法截断的流式轮次也被踢出 store。但那是一次完整、且客户端确实收到了的
回复:模型停下的原因客户端看得见、也能据此续写,OpenAI 允许从它链式续接,而
非流式路径一直是照旧提交的

实测:流式 length 不可链、非流式 length 可链 —— 同一个 finish_reason 在两条
路径上行为分叉。这是本仓库"修复只覆盖部分路径"陷阱的又一次复发。门改为只挡
"上游从未给出终止信号"这一种情况。

response store 不再静默删除超额消息

capEntryBytes 的反向游走在"已保留一条、下一条放不下"时就停,所以一条超过单条
上限、且后面还有更小消息
的消息会被整条排除。而剩下的消息此时已放得下,于是
内容级裁剪的兜底分支不触发 —— 截断标记根本不会写。

实测(RESPONSE_STORE_MAX_BYTES=8m):一条 2MB 的 user 提问 + 一句简短 assistant
回复,存下来是 ["assistant"] —— user 的提问连同粘贴的文件整条消失,既无标记也
无日志
。下一轮续接时模型被问及它从未见过的内容,而后以 200 给出通顺但无上下文的
回答 —— 正是 response store 这个模块声明"要消除"的静默错答模式。

这个形态很普通:README 与 .env.example 明确建议小 VPS 调低该预算,2MB 的粘贴
内容也远在 10MB 请求体上限之内。现在丢弃消息时会把说明前置到第一条存活消息上,
并记 warn 日志。


工程

这一轮的教训值得单独记:v3.9.1 的三条缺陷都不是"漏改了什么",而是"修复本身
建立在一个没验证的前提上"。

那个前提是"每条真实成功路径都会发 finish_reason"。我发版前逐条读过 8 条流终止
路径并认为它成立 —— 但漏掉了第 9 种:代理自己合成的终止帧。读代码找得到"谁发
终止帧",找不到"谁伪造终止帧"。

所以本轮修复的守卫都直接咬合成帧这个形态,而不是只咬"缺终止帧"。

3 条修复各带守卫,全部做过突变验证(把原始错误形态重新注入,确认对应守卫失败)。
测试 3054 → 3058,全量绿(npm run test:release,逐文件进程隔离)。


升级:git pull && 重启,或换用新版二进制 / 镜像。