Repository navigation
Conversation
新增 test/ —— 45 条测试,全部走 loopback mock 上游 + 真实代理进程, 不需要真 key、不访问 Command Code 或 npm registry,可在 CI 无凭据运行。 为什么是端到端而非单元:proxy.mjs 是单文件、无可导入的内部符号,且真正的 契约在「发往上游的 wire 格式」上 —— 只看 HTTP 状态码看不出静默丢消息这类 缺陷(见 issue MAXeaglet#30)。故测试挂在 mock 上游收到的 /alpha/generate 请求体上。 四个文件: test/helpers.mjs mock 上游 + 代理进程管理(临时 cwd,复刻真实部署) test/endpoints.test.mjs 4 个路由的正常路径契约 (8) test/wire.test.mjs 发往 CC 的请求体形状 (8) test/errors-limits.test.mjs 错误映射表 + body/在途上限 (19) test/fingerprint.test.mjs 指纹/lifecycle 预请求协议 (7) test/regressions.test.mjs 已修 issue 的回归护栏 (3) 值得单独说明的几条: - 错误映射逐条覆盖 CC_STATUS_MAP,含 402 -> 429(payment → rate limit)、 422 -> 400、403 -> 401、未列出的状态 -> 502 upstream_error - 指纹 components 字段集合与 CLI 逐字对齐(不多不少)—— 字段偏离是 实现指纹的典型破绽 - MAXeaglet#7 的边界:超限返回 413 且连接保持可用,以及默认上限必须放行 ~9MB 的 多模态请求(下调默认值会挡住这类合法请求) - MAXeaglet#25 的边界:Anthropic input_tokens 只计非缓存部分 CI(.github/workflows/test.yml): - push 全分支 + PR + workflow_dispatch - Node 18 / 20 / 22 矩阵,覆盖 engines 下限与 Dockerfile 使用的版本 (已在 18.20.4 / 20.19.0 / 24.20.0 三个版本实测全绿) - 单列一条 node --check,避免语法问题与测试失败混淆 本地:npm test
- 工作流:push master/release + PR + 手动触发;Node 18/20/22 矩阵;
concurrency 取消同分支旧运行;timeout 10min
- 测试助手修复(两处,都会污染环境或造成偶发失败):
1. allocPort 原用 pid 派生端口区间,node --test 各文件并行时
pid 取模会撞桶(如 pid 100 与 600 同桶)-> 偶发 EADDRINUSE。
改为让内核分配(listen 0)后立刻释放。
2. startProxy 用 mkdtempSync 建临时工作目录却从不删除 ->
每次运行泄漏 N 个目录。改为在 kill() 的两条退出路径上递归删除。
两个问题都在 CI 上暴露过,本地(只有 Node 24)发现不了: 1. server.close() 只停止接受新连接,会一直等到既有连接结束 —— 遇到 keep-alive 或对端未关闭的 socket 就永远等下去。CI 上曾因此三个矩阵 job 全部空转满 10 分钟被取消。helpers 新增 closeServer(): 先 closeAllConnections 再 close,并叠加 3s 兜底超时。 2. 一度用 --test-timeout=30000 防挂起,但该选项 20.11 才加入, Node 18 直接报 "node: bad option",把 engines 下限打挂。 改在 helpers 里起 unref 定时器兜底:进程若无法退出,到点强制 exit(1) 并打印排查提示;正常结束时不阻止退出。 两者都兼容 Node 18。
This was referenced Sep 14, 2026
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
pushed a commit
that referenced
this pull request
Sep 16, 2026
…/ 无 finish 事件) (#39) * fix: 上游没正常走完 finish 时不再谎报成功(issue #38) 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 全绿。 * test: 带上 #38 的回归测试(含最小测试脚手架) 本 PR 只带这一组测试所需的脚手架:test/helpers.mjs 与 package.json 的 test 脚本。 helpers.mjs 与 PR #34 中的文件逐字节一致,两边先后合并都不会冲突(git 对 「两侧新增同一份相同内容」不视为冲突)。 15 条断言 = 三种协议(chat / messages / responses)× 四种情形(截断类 finishReason、 pause_turn、provider 连接失败、无 finish 事件),外加两条「正常结束不受影响」的回归。
Owner
|
感谢贡献!该 PR 中的完整测试套件与 GitHub Actions CI 配置已随 PR #58 完整合流并扩展至 146+ 条用例覆盖。 |
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.
这个 PR 做什么
新增
test/测试套件 + GitHub Actions CI。当前仓库没有自动化测试(只有一条 docker 发布工作流),回归只能靠人肉复现 issue。45 条测试,全部在 CI 可跑:loopback mock 上游 + 真实代理进程,不需要真 key、不访问 Command Code 或 npm registry。
为什么是端到端,不是单元测试
proxy.mjs是单文件、没有可导入的内部符号 —— 单元测试要么把函数抠出来(那测的就不是发布的那个文件),要么改造成多文件(违背项目定位)。params.messages是空的,用户的问题压根没送到模型。所以断言挂在 mock 上游收到的
/alpha/generate请求体上。覆盖
endpoints.test.mjswire.test.mjserrors-limits.test.mjsfingerprint.test.mjsregressions.test.mjs几条值得单独说的:
错误映射逐条覆盖
CC_STATUS_MAP,包括容易忽略的三条语义转换:指纹
components字段集合与 CLI 逐字对齐(15 个字段,不多不少)。字段偏离是实现指纹的典型破绽,值得钉死。#7 的边界:超限返回 413 且连接保持可用(#7 的症状是 client 只看到 Connection error,所以断言的是「413 + 排空」而不是阈值数字);同时断言默认上限必须放行 ~9MB 的多模态请求 —— 下调默认值会挡住这类合法请求。
#25 的边界:Anthropic
input_tokens只计非缓存部分(断言 200 而非 1000)。CI
engines: >=18下限与 Dockerfile 的node:22-alpine。node --checkjob:语法问题会让进程启动即崩,与断言失败混在一起不易定位。这套测试确实抓到了东西
不是「加了测试以防万一」——它在开发过程中实际拦下两个缺陷,都属于不看 CI 就发现不了的类型:
1. 预请求漏了
User-Agent: cli。 官方 CLI 的指纹预请求与生成请求共用同一张 header 常量表,两者 UA 一致。而代理只在一处设了 UA,ensureInitialized的 fingerprint / lifecycle 两条预请求走的是 Node 默认的node—— 同一账号的「设备指纹注册」与「生成请求」来自两种 User-Agent,是服务端可直接观测的破绽,且恰好落在设备识别入口上。2.
--test-timeout打挂 Node 18。 我一度用它防挂起,但该选项 20.11 才加入,Node 18 直接报bad option—— 一个防挂起的措施反而把engines下限打挂了。矩阵当场抓到,已改为 unref 看门狗(Node 18 同样可用)。关于挂起
测试套件必须有界退出,否则会吃掉整个 job。两处措施:
closeServer():先closeAllConnections再close,叠加 3s 兜底。server.close()只停止接受新连接,遇到 keep-alive 或对端未关闭的 socket 会永远等下去。helpers.mjs里一个 unref 定时器:进程若无法退出,到点强制exit(1)并打印排查提示;正常结束时不阻止退出。说明
proxy.mjs。全部 45 条在当前master上通过。CC_MAX_BODY_MB=1测超限分支),避免与 请求体超过 10MB 时 req.destroy() 导致客户端仅显示 Connection error #7 那类默认值讨论耦合。listen(0)后释放),不用 pid 派生区间 —— 后者在node --test并行运行时会撞桶,我们实测到过偶发EADDRINUSE。