Repository navigation
fix: 流式 /v1/responses 零输出防护失效(#56,#54 回归)—— 空响应不再谎报 response.completed - #57
Merged
MAXeaglet merged 23 commits intoOct 9, 2026
Merged
Conversation
仅两处改动,对齐官方 cmd CLI 1.53.0 的线上行为(源码 + 抓包双重确认):
1. /alpha/generate 请求补上 User-Agent: cli
CLI buildCommandAuthHeaders 里该头取自常量 vy = "cli"。
MAXeaglet 此前完全不发 User-Agent。
2. 信封顶层补上真实 threadId,取值与 x-session-id 恒等
CLI createModelClient 实际发送 body.threadId === x-session-id。
MAXeaglet 原有的 newThreadId() 是死代码(495 行算完从未进入请求体)。
- 客户端提供 session 头 → threadId 取该值
- 客户端未提供 session 头 → 仍走原有 per-key 12h 会话,threadId 取同一值(该路径行为不变)
- sessionId 非合法 UUID → 省略该字段,对应 CLI toWireThreadId 语义
(uuid.safeParse 失败即返回 undefined,键被 JSON.stringify 丢弃)
threadId 在信封中占位于 permissionMode 与 params 之间,保持与 CLI 一致的键序。
已实测(本地 mock 上游):
- 两个端点(/v1/chat/completions、/v1/messages)UA 均为 cli,threadId 均等于 x-session-id
- 客户端未发 session 头时,threadId 与生成的 x-session-id 相等
- 客户端发非 UUID 时,threadId 键整体消失
说明:本分支原在此提交一并实现了 CC_MAX_INFLIGHT,rebase 到上游 c443889 后该项已由上游实现且更完整 —— 上游版在 server 入口统一准入、/health 与 / 豁免、finish+close 双事件幂等释放。本提交已把本地那套全部撤下,只保留上游 实现;本分支也不再提供 config.json 的 maxInflight(与 CC_MAX_BODY_MB / CC_STREAM_IDLE_MS 等调参项一致,只用环境变量)。 Finding 1(响应背压)已由上游 88a1872 修复;Finding 2(请求树副本)由本提交 补齐;全局在途上限由上游 c443889 补齐。 - buildCcRequest 之后释放原始请求树 - /v1/chat/completions:取出 prompt_cache_key 后置空 openaiReq - /v1/messages:置空 openaiReq 与 anthropicReq 原先这两棵树会和 ccBody 一起活到整段请求结束。 新增 upstreamProxy / CC_UPSTREAM_PROXY,让发往 CC 上游的请求走本地 HTTP 代理(出口地区调整 / 风控 403 的 IP 维度对照)。 覆盖 /alpha/generate、/alpha/fingerprint/record、/alpha/lifecycle-events、 /provider/v1/models;不影响本地监听、/health 与 npm 版本检查。 实现为零依赖:自建 CONNECT 隧道(代理只做裸字节转发),再用 node:https 复用同一 socket,TLS 端到端、证书按目标主机名校验。因此不需要 undici / https-proxy-agent,engines >=18 即可用(Node 原生 fetch 不读 HTTPS_PROXY; 官方环境变量路线需 Node >= 22.21/24.5 + NODE_USE_ENV_PROXY=1)。 预请求刻意也走代理:若它们直连而上游生成走代理,同一账号会从两个不同 IP 注册,正是该 issue 想消除的矛盾。 - 默认路径:UA=cli、threadId===x-session-id、恰好 1 条 generate - 配代理:CONNECT 隧道建立、generate 与两个预请求均经隧道、/health 不经 代理、UA 仍为 cli - 两个端点(OpenAI/Anthropic)无回归,Anthropic 端点经代理亦正常
上一版把默认值降到 8MB 是错的。核过 MAXeaglet#7 与其修复提交 573e260 后确认:MAXeaglet#7 的 真实触发场景是多模态长会话(21 张 base64 图片累积约 10.11 MiB 的合法请求), 任何 4~8MB 的默认值都会把这类请求整体挡在门外。 需要分清的是:MAXeaglet#7 的「客户端只看到 Connection error」症状由「超限返回 413 + 排空连接」这条路径解决,与阈值取值无关;但阈值决定的是功能边界,而真实的 多模态上下文确实会到 10MB 量级,所以默认值必须留足。 结论:既然 body 阈值必须够大,要约束的就是并发侧 —— 这个角色由上游 c443889 的 CC_MAX_INFLIGHT 承担(本分支不再自带实现,只保留上游那套)。本提交因此 只做两件事:还原默认值,并在两个 README 的「在途上限」章节补一段「为什么 body 默认值不能降」的依据,顺带修掉「内存与部署」里那句已过时的「proxy 自身 没有在途限流」。 复测: - 默认(100MB) 放行 9MB 请求 -> 200(MAXeaglet#7 的多模态场景不再被挡) - CC_MAX_BODY_MB=8 时同一请求 -> 413,拒绝后小请求仍正常 - 其余验证项(UA/threadId、上游代理)全部通过
修三个问题:指纹漂移(进程重启 / 多实例 / 每 12h 被动重置)、
以及 thumbmark 与 hashSignal 的构造与官方 CLI 不一致。
指纹由 HMAC-SHA256(CC_FP_SALT, apiKey) 确定性派生,一个 key 恒定一台设备:
进程重启 Map 清空 → 换一台机器 → 同一台机器
第二个实例 同一 key = 两台机器 → 同一台机器
session 过期 12h keyStateStore.delete → 每 12h 换一台机器 → 同一台机器
第三条是原实现最明显的破绽:真实用户不会一天换两次电脑,而上游
device_fingerprints 表按 (userId, thumbmark) 建唯一索引。
刻意**不是**"按 key 取哈希桶选设备":固定池的熵上限就是池大小,key 数
一旦超过池容量就必然出现多 key 共用指纹(模拟:50 key / 1000 池 → 约 2 个
碰撞;50 key / 100 池 → 约 20 个),而共用 thumbmark 正是"多账号同机"的
直接证据。派生方案每个 key 仍是独立设备,碰撞概率 2^-256。
CC_FP_MODE=random 保留原「每进程随机」行为作为回退。
原 keyStateStore.delete 写在 session 清理循环里,本意是内存回收,
实际效果是每 12h 重置指纹。现在指纹状态有自己的「按空闲淘汰」(24h),
且因为指纹是派生的,淘汰后重新派生得到的仍是同一台设备,不构成漂移;
活跃 key 的 nextInitAt 也不再被重置,避免重复发送预请求。
源码(buildMachineFingerprint / hashSignal):
ib = "command-code:device-fingerprint:v1"
hashSignal(v) = sha256(ib + "\0" + v.trim().toLowerCase()) // v 是原始值
thumbmark = sha256(ib + "\0machine\0" + [machineId, macs.join(",")].join("|"))
machineId 非空时 hostname / cpuModel 不参与
原实现:直接对随机 hex 求 sha256(缺 ib 前缀),且 thumbmark 由各
component 的**哈希**拼成、另加 platform/osRelease/cpuModel 等字段。
上游拿不到原始 machineId、无法重算,所以这处不一致本来就检测不到;
既然要动就一次对齐。components 的字段集原本就是对的(runtime: "cli"、
collectorVersion: 1 都对),本次只改哈希构造。
指纹套件(本地 mock 上游,起停真实进程):
- components 字段集与 CLI 完全一致(15 个),runtime/collectorVersion 正确
- 重启后同一 key -> 同一 thumbmark;CC_FP_MODE=random 下则不同
- 40 个 key -> 40 个不同 thumbmark,零碰撞;CPU 14 种、时区 14 种分布
- 不同 CC_FP_SALT -> 不同设备;同一盐 -> 稳定
白盒交叉验证(关键):
- 独立复刻 CLI 算法,对同一组原始值算期望值,与运行中代理实际上报的
5 个 key × 15 个字段逐字节比对 -> 全部一致
回归:MAXeaglet#20 内存、MAXeaglet#18 上游代理、UA=cli、threadId===x-session-id 全部通过。
原文档写「把 timezone 绑定到出口 IP 是自然的下一步」,这是错的。 command-code CLI 的 readTimezone() 是 Intl.DateTimeFormat().resolvedOptions().timeZone 即**客户端本机操作系统的时区**,与流量从哪个 IP 出去无关。 中国大陆用户通过代理访问本服务时,本机时区与出口地不一致是常态。 真正有意义的性质是时区在**自身用户群**里的分布,而不是与出口 IP 是否一致: 单一地区用户群若从 15 个全球时区里均匀取,会让每个账号看起来来自不同大洲。 正确做法是让池子匹配实际使用该部署的人群(收窄或加权 FINGERPRINT_TZS), 这是运维决策,不是「对齐 IP」。
上游有测试之前,fork 的这些改动只能靠手动脚本验证。现在把它们纳入 CI, 避免在上游同步时被静默覆盖。 新增 test/fork.test.mjs(13 条): 逆向对齐(对应 e3e267e) - 上游请求 User-Agent 为 cli(CLI 源码常量 vy = "cli") - fingerprint/lifecycle 预请求同样是 cli - threadId 与 x-session-id 同值 - session 非 UUID 时省略 threadId(CLI toWireThreadId 行为) 上游代理(对应 MAXeaglet#18) - CC_UPSTREAM_PROXY 配置后经 CONNECT 隧道 - 预请求也走代理(否则同账号会从两个 IP 注册) - 未配置时不建立任何 CONNECT - /health 不经过上游代理 指纹派生(对应 c153a76) - 同 key 同盐重启后 thumbmark 稳定 - 不同 key 得到不同 thumbmark(不退化成哈希桶) - 不同盐得到不同部署指纹 - CC_FP_MODE=random 可回退到原行为 测试用录制型 CONNECT 代理(只转发裸字节),不需要真出口代理。 全部走 loopback,无凭据、不访问外部服务。
fork.test.mjs 里 8 处 startMockUpstream() 漏了 await(我用 sed 替换动态
import 时引入)。mock.port 因此是 Promise,代理连不上、mock 又永不关闭,
事件循环被挂住 —— CI 三个矩阵 job 全部空转满 10 分钟后被取消。
两处修复:
- 补回 await(8 处)
- 让"挂起"有界,避免同类问题再次吃掉整个 job:
· helpers 新增 closeServer():先 closeAllConnections 再 close,
并叠加 3s 兜底超时。server.close() 只停止接受新连接,遇到
keep-alive / 未关闭的 socket 会永远等下去。
· npm test 加 --test-timeout=30000,单条测试超 30s 即失败。
CI 抓到的真实 fidelity 缺口:CLI 侧指纹预请求与生成请求共用同一个 header 常量表(lb = vy = "cli"),两者 UA 一致。而代理只在 forwardToCC 设了 User-Agent: cli,ensureInitialized 里的 fingerprint/lifecycle 两条预请求 走的是 Node fetch 默认的 "node"。 后果是同一账号的「设备指纹注册」与「生成请求」来自两种 User-Agent —— 这是服务端可直接观测的破绽,且恰好出现在设备识别入口上。 同时修正 fork.test.mjs 里一条断言错误的契约: getSessionId 接受任意 >=8 字符的 session 头(不限 UUID),此时 threadId 必须等于实际发出的 x-session-id。原断言假设非 UUID 会被丢弃,与实现不符。 改为断言真正的不变量(threadId === x-session-id),并补一条无 session 头 时的回落路径。
CI 暴露我把两个不同的契约混为一谈:
x-session-id header —— getSessionId 接受任意 >=8 字符,原样透传
body.threadId —— 仅当 sessionId 是合法 UUID 时才写(isWireUuid),
否则省略该字段,对齐 CLI 的 toWireThreadId
上一版断言「非 UUID 时 threadId 仍等于 header」,与实现不符。现在按实现
的真实契约分别断言,并补一条无 session 头时的回落路径(回落值是合法的
per-key UUID,此时 threadId 必须与之同值)。
CI 的 Node 18 job 报 "node: bad option: --test-timeout=30000" —— 该选项 20.11 才加入,而 engines 声明 >=18。等于我用一个防挂起的措施 把最低支持版本打挂了。这正是矩阵存在的意义:本地只有 Node 24, 不加矩阵就会直接发出去。 改为在 helpers 里起一个 unref 的定时器:进程若因泄漏的 socket / 未 await 的句柄无法退出,到点强制 exit(1) 并打印排查提示。 正常结束时定时器被 unref,不阻止退出。Node 18 同样可用。 保留上一版的 closeServer()(先 closeAllConnections 再 close + 兜底超时)—— 那才是真正修掉「不退出」的手段,看门狗只是兜底。
上游 9 个 commit 合入,冲突 8 处(proxy.mjs 6 + README ×2)全部解决。 原则:协议形态一律取上游(它以 CLI 源码 + 真机为准),fork 独有特性保留。 取上游的部分: - params.system 改为块数组(CLI 的 toWireSystem)、缓存断点落 system 末块 - 信封 9 键 + CLI 键序、skills=null、mode、threadId 仅 UUID 时下发 - 请求头按 buildCommandAuthHeaders 排序、删除 x-co-flag、UA=cli - tools 总是下发且去掉自创 type;tool 输出 \n 拼接;image 补 mimeType - x-project-slug = slugify(workingDir),不再随 session 变化 - 指纹信号值改为按 apiKey 派生(MachineGuid 形状 / MAC / DESKTOP- 主机名 / 可读邮箱), 候选池用「打分取最大」而非取模;协议版本改为常量 + 漂移告警 - DEVICE_PROFILE 单一真源:指纹 / environment / workingDir / slug / lifecycle.os 自洽, 不再泄露宿主 platform / Node 版本 / cwd - 流式 stop_reason:先过 mapFinishReason 再进 mapAnthropicStopReason (上游 finishReason 是 'tool-calls' 连字符,此前 Anthropic 流式路径会误报 end_turn) - 上游 error.code 透出(OpenAI 路径) 保留 fork 的部分: - upstreamProxy / CC_UPSTREAM_PROXY 的 CONNECT 隧道(issue MAXeaglet#18) - 预请求同样带 User-Agent: cli(上游仍只有 forwardToCC 设了它) - 请求树提前释放(issue MAXeaglet#20)、CC_MAX_BODY_MB 默认 100MB、空闲淘汰 keyState 移除 fork 的部分(已被上游更好的实现取代): - fpMode / CC_FP_MODE / fpSalt / CC_FP_SALT → fingerprintSalt / CC_FINGERPRINT_SALT - fakeProjectSlug(盘符被错误剥掉且有自造后缀,与 CLI 的 slugify(cwd) 不符) - 重复的 body.threadId 赋值(上游的键序重排已覆盖) 测试:58 → 69,全绿。 - 修正 2 条编码了旧认知的断言(params.system 曾断言「必须是字符串」) - 修正 fork 指纹用例的配置项名;用「信号形状」用例替代已删除的 random 回退用例 - 新增 test/envelope.test.mjs(11 条):信封键序、非 UUID 键序、skills=null、 header 集合无 x-co-flag、slug=slugify(workingDir) 且与 workingDir 同源、 workingDir 不泄露宿主 cwd、tools 总是下发、tools 无 type 字段、 image 带 mimeType、信封 mode 与 lifecycle mode 是两个枚举
上游 d063b47 引入的 TOOL_NAME_ALIASES 取错了来源,作用方向也反了。 对照 command-code@1.54.0 dist/cli.mjs: nw="search_tools", rw="tool_search" function toWireToolName(e){return e===rw?nw:e} ← 线上只有这一项 function toWireTools(e){return e.map(e=>({name:e.name, ← 声明不做重写 description:e.description,input_schema:e.input_schema}))} // toWireMessages 里:const r=toWireToolName(t.name); n.set(t.id,r); // tool-call 用 r,tool-result 用 n.get(id) —— 两边都是重写后的名字 另外三项(bash_output / task_output / read_multiple_files)来自 ow 表,消费者是 resolveToolNameAlias:模型调了退役工具名时本地按新名字执行,并回一句给模型看的 自然语言 note,还会补 defaults(task_output 补 wait:"exit")。那是执行语义, 不是 wire 变换。 原实现:把四项全用在 params.tools[].name(CLI 不改的地方改了), tool-call / tool-result 反而用原名(CLI 该改的地方没改)。下游按自己声明的 bash_output 找不到工具 —— 即 issue MAXeaglet#36 的现象。 修复: - WIRE_TOOL_ALIASES = { tool_search: 'search_tools' },附注释说明 ow 表为何不能照搬 - params.tools[].name 原样下发 - toolNameMap 存重写后的名字(对齐 CLI 的 n.set(t.id, r)) - assistant 的 tool-call 与 tool-result 都用 toWireToolName,保证两边一致 已向上游提 issue MAXeaglet#37(含源码证据与建议修法)。 测试:73 全绿,新增 4 条 wire 契约: - tools 声明不做名字重写 - tool_search 在 tool-call 里被重写为 search_tools - tool-result 的 toolName 与 tool-call 一致 - 别名表外的名字在声明与 messages 里都不动
383aed8 只做到一半:MAXeaglet#37 里我建议「照搬 CLI,声明不改、消息改」,保留了一项 tool_search→search_tools。继续深挖 CLI 源码后发现那个方案本身也不成立。 CLI 里两个改名函数的真实定位(command-code@1.54.0 dist/cli.mjs): createSearchToolsTool → schema.name = nw = "search_tools" createRetiredToolSearchTool → schema.name = rw = "tool_search", visible:()=>false description: "Retired: use search_tools instead. Calls to the tool_search NAME are routed to search_tools by the runner automatically." createToolRunner: o = () => [...tools, search_tools, retired] catalog 的 eligible() = n.filter(isVisible),发起请求那次 getSchemas({mode}) 不带 includeHidden(只有本地 resolveToolSchema 才开) → params.tools 里永远没有 tool_search toWireToolName(e){return e===rw?nw:e} 只作用在 toWireMessages toWireTools(e){return e.map(e=>({name,description,input_schema}))} 原样 resolveToolNameAlias(ow) 被工具执行器调用,产出给模型看的 Repair note + 补 defaults 即 toWireToolName 不是「工具重命名设施」,而是「把自家 catalog 里那一个退役名字的 历史归一化」—— 前提是 CLI 自己退役过工具名、且可能重放旧会话。 反代没有这个前提:params.tools 由下游给出,没有 catalog、没有退役名。 而且只改消息不改声明本身就是不自洽的:客户端一旦声明了名为 tool_search 的工具, 就是「声明 tool_search、消息 search_tools」,复现 MAXeaglet#36 的同一个 bug。 修复:删除 WIRE_TOOL_ALIASES 与 toWireToolName,三处调用点退回原名, 原处留一段墓志铭注释说明两个 CLI 函数的真实定位与「将来要支持旧会话该做成入站」。 测试同步改为断言「不重写」。 结论已发到 issue MAXeaglet#37(含 cross-ref MAXeaglet#36)。
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 全绿。
CLI 的 gatherRawSignals 里 cpuCount 取的是 os.cpus().length,即**逻辑处理器(线程)数**, 而 FINGERPRINT_CPUS 填的全是物理核心数 —— 15 项逐项核对,无一正确。 为什么这条不只是「不够真实」: components 里只有 machineIdHash / macHashes / osUserHash / hostnameHash / gitEmailHash 走哈希,**cpuModel 与 cpuCount 是明文上传的**,服务端可以把这一对交叉核对。 「i7-12650H + 10 线程」等价于「这台机器关掉了超线程」;原表 100% 都落在 这个罕见表述上,是群体分布层面的特征,不是单请求能看出来的那种。 改法:表里补 threads 字段,cpuCount 改用 threads。 数字逐项复核过,其中 Intel Core Ultra 9 285H 是个反直觉项: Arrow Lake 取消了超线程,6P+8E+2LPE = 16 核 == 16 线程, 所以它和 Ultra 7 155H(Meteor Lake 有超线程,22 线程)不能套同一个公式。 实现参考 @jinyu2022 的 PR MAXeaglet#35(那份 PR 还包含 MAC OUI 真实化,本次未采纳: MAC 走的是哈希、明文不上网,收益只是外观;而它会改变 thumbmark 的输入, 等于让所有已部署的 key 换一台设备,代价大于收益)。 新增测试锁定「cpuCount == 该型号的逻辑处理器数」这一对不变量。 全套 89 项全绿。
线上排查时发现两条,一真一噪:
【噪音】Unknown CC event type {"type":"text-start"}
上游每个响应都会发一串不携带内容的事件(text-start / text-end / start / start-step /
reasoning-start / reasoning-end / provider-metadata / tool-input-* / tool-error)。
这些在三条**流式**翻译器里都有 case,但三条非流式路径要么缺静默列表、要么根本没有 ——
Responses 非流式那条压根没有静默列表,于是每个响应都刷十来个 warn,
把真正的错误淹掉。
修法:四条路径统一成同一份静默列表(只列"无用户可见内容"的事件),
default 仍然保留警告,真正没见过的类型照旧留痕。
【真问题】mapCcEventError 丢掉了上游自带的状态
CLI 的 readStreamErrorEvent 读的是 error.statusCode / error.isRetryable,
取值链:parseEmbeddedErrorJSON(message)?.status ?? error.statusCode ?? null
原实现只看 message 里的 "<NNN>" 前缀,statusCode 一律被丢掉,
于是一律塌成 502 upstream_error。
后果:429/503 这类「该退避重试」的信号在代理这一层被抹平成「服务端错误」——
客户端不再按限流退避,监控也把它错误归类成后端故障。
实测线上那条 "The request limited providers for this model and they are currently
at capacity"(不含 CLI 的任何 terminal 标记:premium_credits_exhausted /
model_not_in_plan / insufficient credits,即按 CLI 口径它是可重试的)
就可能因此被记成 502 而不是 429。
修法:
- mapCcEventError 采纳 error.statusCode(<NNN> 前缀仍优先,与 CLI 一致),
返回值增加 reportedStatus 便于区分「上游报的」与「我们映射后的」
- 四个 CC error 日志点改为先映射再记日志,并打出 upstreamStatus / upstreamRetryable /
code / mappedTo —— 与作者 78353d9 对 mapCcError 的处理保持一致
测试:20 条(MAXeaglet#38 家族)→ 全套 94 条全绿。
新增 4 条 statusCode 映射断言 + 1 条「标准序列不产生 Unknown CC 警告」。
线上排障定位到:流空闲超时后走的是
res.write(`data: ${error}\n\n`);
res.destroy();
res.write 是异步的,紧接着 destroy 会把尚未刷出的缓冲丢掉并发 RST。
反向代理侧看到的就是 "upstream prematurely closed connection":
响应头还没转发给客户端时回 502,已经转发了就是客户端看到 connection error /
截断的流。**两个症状同源。**
线上证据(1c2g VPS / OpenResty + systemd,资源指标全部健康:
NRestarts=0、MemoryCurrent=215MB、LimitNOFILE=524288、CPU 1.5%、无 OOM):
Stream idle timeout {elapsedMs:62448, bytesReceived:642575, lastCcEvent:"reasoning-delta"}
Stream idle timeout {elapsedMs:87201, bytesReceived:688653, lastCcEvent:"text-delta"}
Stream idle timeout {elapsedMs:139706, bytesReceived:815758, lastCcEvent:"reasoning-delta"}
Stream idle timeout {elapsedMs:147005, bytesReceived:833893, lastCcEvent:"reasoning-delta"}
833KB / 147s ≈ 5.5KB/s —— 上游确实慢(推理模型 + 容量受限),30s 空闲阈值
(CC_STREAM_IDLE_MS 默认值)在这种流上会频繁误杀。超时本身也许合理,
但收尾方式不对,把"代理主动截断"变成了"代理把客户端连接搞断"。
修法:三处流式超时收尾(OpenAI / Anthropic / Responses)改为
res.end(errEvent) —— 把错误事件正常写进 SSE 流再发 FIN,客户端 SDK 能按
可重试错误处理。下游若已僵死(不读也不断),仍由 CLIENT_DRAIN_TIMEOUT_MS
那条路径负责强制断开,职责不变。
测试:新增 1 条,且**验证过有区分度** ——
把 end() 换回 destroy() 时该用例失败,客户端拿到 "TypeError: terminated"
(连接被重置);换回 end() 通过。这正是线上 connection error 的复现。
test/helpers.mjs 增加 onRequest 返回 true 即"接管响应"的能力,
用来模拟"上游发了一半就长时间没新数据"。
全套 95 项全绿。
线上 nginx error log 的主要错误(8/10 条):
sendfile() failed (32: Broken pipe) while sending request to upstream
request: "POST /v1/chat/completions HTTP/1.1"
upstream: "http://127.0.0.1:3050/..."
含义很具体:nginx 正在**把请求体写给后端**时,后端把连接关了。
注意是 sendfile() 而不是 writev() —— 这些请求体大到被 nginx 缓冲落盘。
而 POST 是非幂等,nginx 默认不会重试已发出的请求 → 客户端直接吃 502。
根因是 keep-alive 的时序:反代的 upstream keepalive_timeout 必须**小于**
后端的 keepAliveTimeout,否则反代会从缓存里取出一条后端已经关掉的连接。
Node 默认 keepAliveTimeout 是 5s,反代常见的 4s 只留了 1 秒余量;两边的
计时基准还不一样(反代从"读完响应放回缓存"起算,后端从"写完响应"起算),
大响应体下这点余量随时会被吃掉。线上恰恰全是 600~830KB 的流式响应。
此外 proxy 之前**没有设置过** server.keepAliveTimeout,等于把这件事完全交给
Node 默认值与反代配置的巧合 —— 部署形态(反面代理)是已知的,不该靠巧合。
改法:显式 server.keepAliveTimeout = 65s、headersTimeout = 66s
(CC_KEEPALIVE_TIMEOUT_MS 可覆盖),与 Node 官方"部署在反向代理之后"的建议一致
(keepAliveTimeout > 前端 idle timeout)。启动横幅打出该值,便于与反代对齐。
反代侧仍建议把 upstream keepalive_timeout 设成 60s 以内(不是 4s)——
现在两侧都是分钟级,余量从 1 秒变成几十秒,不再取决于抖动。
测试:新增 1 条锁定"启动横幅必须打出 keepAliveTimeout 并提示反代对应项",
全套 96 项全绿。
只报上限等于让人去猜自己超了多少:客户端要据此决定拆请求还是申请提额, 运维要据此决定 CC_MAX_BODY_MB 该设多大。 nginx 开了 proxy_request_buffering 时会带 Content-Length,据此给出真实体积; 没有该头(chunked)时退回已收到多少并标注为下界。同时补一条 warn 日志便于统计。
【竞态】31ce5e2 的 CI 在 Node 20/22 上失败、18 通过:
not ok 37 - 413 报错包含实际请求体积(客户端要知道超了多少)
AssertionError expected: true actual: false
失败的是最后一条 assert.ok(s.proxy.logs().includes(...)) —— 代理的日志经 stdout
异步送到测试进程,可能晚于 HTTP 响应到达。同一进程里日志确实先于响应写出,
但 stdout 与 TCP 响应是两条独立通道,父进程处理顺序没有保证。Node 18 恰好赶上、
20/22 没赶上。
前两条 assert.match("exceeds 1MB limit" 与 "body is N.NMB")在三种 Node 上都通过 ——
说明**修复本身是对的**,不稳的只是我对日志的断言。改为有界轮询(最多 2s)。
【矩阵】原来只跑 18/20/22,而实际部署(systemd 服务)跑的是 Node v24.16.0 ——
生产版本不在 CI 里是明确的漏洞。补上 24,并更新注释说明选型理由。
本机 Node v24.20.0 下全套 97 项通过。
- 18/20 已出维护期,CI 不再覆盖(engines >=18 仅作声明) - 新增 test-bun job:Bun 运行时跑全套测试,被子测代理经 process.execPath 派生,Bun 下整条链路都是 Bun - Bun 的 node:http 基于 fetch 实现,不支持 CONNECT 方法 (发起即报错),issue MAXeaglet#18 的两个隧道用例在 Bun 下显式跳过; 新增 npm run test:bun 便于本地对齐
…glet#54 工具截图单发 + MAXeaglet#32 跟进 冲突取舍: - 指纹 CPU 表取我方 threads 版(上游是 TEMP-REVERT 旧表,且合并后代码依赖 .threads) - 三处非流式静默列表的 finish-step 取我方:上游侧与同 switch 的实质处理重复,是死代码 - MAXeaglet#18 跟进(redactProxyUrl 日志脱敏 / 启动即校验 / 204/205/304 空 body)与 MAXeaglet#50 重试取上游 - 启动横幅两边合并:redact 后的 upstreamProxy + upstreamRetry + fingerprint 三行齐全 - helpers/stream-end 测试取我方(严格超集:handled 接管契约 / +116 行回归用例) - README:环境变量总表与 Docker 表取上游版;设备指纹与工具截图预算两章都保留
…completed MAXeaglet#54 把 response.created 提前到上游一返回 200 就发(治首字前 15~40s 静默期被 nginx/CDN 掐连接),translator.started(=createdSent)自此恒为 true, 「outputTokens===0 && !translator.started」成了死代码:空响应经 finish() 包装成 response.completed 谎报成功(旧版 cce214d 是 429 rate_limit_error), 与 MAXeaglet#38/MAXeaglet#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 上 - 非流式路径未被 MAXeaglet#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.
Fixes #56
根因
#54(
2eccdbf)为治「首字前 15~40s 零字节静默被 nginx(499)/CDN 掐连接」,把response.created提前到上游一返回 200 就发(proxy.mjs的await writeEvents(translator.start()))。startResponse()会置createdSent = true,而get started() { return createdSent; }—— 于是零输出防护自此恒为假,成了死代码:空响应(
{"type":"start"}+finish(outputTokens=0))落进else分支,经finish()包装成 200 +response.completed(output: [])谎报成功。旧版(cce214d)是429 rate_limit_error。与 #38/#39 修掉的「静默截断谎报成功」是同一类问题,只是这条路径被 #54 漏掉了。修复
get hasOutput()(outputIndex > 0 || doneItems.length > 0,即「是否真的产出过 output item」),替代恒真的!translator.started;created已先行发出),不能再sendResponsesError(res, 429, ...)—— 状态码改不回去,只会抛ERR_HTTP_HEADERS_SENT(issue 里实测的惨案);按本文件既有失败口径走translator.fail(...)写response.failed(status:"failed"、error.code:"upstream_error"、message 沿用Empty response from upstream (zero output tokens));!started → 429子分支兜底(万一未来 created 不再提前发,旧语义仍在);return会跳过流式分支尾部的if (!res.writableEnded) res.end(),就地res.end(),否则客户端挂在永不结束的 SSE 上(issue 作者指出的坑);fullText/thinkingText/toolCalls判空),保持 429 不动。验证
test/responses-zero-output.test.mjs三个用例:response.failed,无response.completed、无Cannot write headers内部错误事件,且流正常收尾(r.text()能读完 = 没漏res.end());response.completed(防误伤);proxy.mjs上,用例 1 如预期红(必须显式发 response.failed);修复后 3/3 绿。stream-end+responses-tool-image+endpoints共 36/36。文档
README / README_zh 的「零输出防护」表项、错误码表
429行、功能清单同步改为:非流式 429;流式因created先行发出走 200 +response.failed。