mslxdff 0.1.203 → 0.1.208

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (332) hide show
  1. package/README.md +3 -3
  2. package/bin/mslxdff.js +1 -1
  3. package/docs/cli_help_mini.md +147 -0
  4. package/package.json +38 -38
  5. package/plugins/README.md +14 -14
  6. package/plugins/prefer-model.mjs.example +14 -14
  7. package/src/anthropic/canonical-json.js +55 -0
  8. package/src/anthropic/count-tokens.js +61 -61
  9. package/src/anthropic/request-body.js +183 -126
  10. package/src/anthropic/request-shape.js +60 -0
  11. package/src/anthropic/session-id.js +58 -0
  12. package/src/anthropic/shared.js +18 -18
  13. package/src/anthropic/sse-aggregate.js +56 -56
  14. package/src/anthropic/tool-media.js +75 -0
  15. package/src/anthropic/translate.js +296 -287
  16. package/src/anthropic/web-search.js +361 -361
  17. package/src/api-kind.js +31 -31
  18. package/src/autostart.js +288 -288
  19. package/src/bench/probe.js +1 -1
  20. package/src/bench/runner.js +1 -1
  21. package/src/bench/via-probe.js +1 -1
  22. package/src/bench/via.js +144 -144
  23. package/src/bench/workbuddy-bench.js +1 -1
  24. package/src/chat/config.js +11 -11
  25. package/src/chat/cooling.js +81 -81
  26. package/src/chat/direct.js +58 -58
  27. package/src/chat/index.js +6 -6
  28. package/src/chat/spinner.js +38 -38
  29. package/src/chat/sse.js +69 -69
  30. package/src/chat/stats.js +247 -247
  31. package/src/chat/store.js +48 -48
  32. package/src/chat/tool-handlers.js +67 -67
  33. package/src/chat/tools.js +1 -1
  34. package/src/chat/upstream.js +59 -59
  35. package/src/chat-pipeline/auto-race.js +158 -158
  36. package/src/chat-pipeline/empty-turn.js +147 -147
  37. package/src/chat-pipeline/engine.js +17 -17
  38. package/src/chat-pipeline/index.js +8 -40
  39. package/src/chat-pipeline/serial-trial.js +279 -283
  40. package/src/chat-pipeline/terminal-verdict.js +132 -147
  41. package/src/chooser.js +54 -54
  42. package/src/claude-desktop/apply.js +101 -101
  43. package/src/claude-desktop/context1m.js +38 -38
  44. package/src/claude-desktop/doctor.js +146 -146
  45. package/src/claude-desktop/slots.js +152 -152
  46. package/src/cli/bootstrap.js +1 -1
  47. package/src/cli/commands/claude-desktop.js +284 -284
  48. package/src/cli/commands/daemon.js +231 -231
  49. package/src/cli/commands/model/list-live.js +22 -22
  50. package/src/cli/commands/model/list-providers.js +76 -76
  51. package/src/cli/commands/model/list-render.js +77 -77
  52. package/src/cli/commands/model/list-sort.js +52 -52
  53. package/src/cli/commands/model/list-tty.js +76 -76
  54. package/src/cli/commands/model/list.js +166 -166
  55. package/src/cli/commands/model/live-models.js +24 -24
  56. package/src/cli/commands/model/stats.js +43 -43
  57. package/src/cli/commands/model/status.js +47 -47
  58. package/src/cli/commands/model.js +51 -51
  59. package/src/cli/commands/provider/add.js +108 -108
  60. package/src/cli/commands/provider/allowlist.js +99 -99
  61. package/src/cli/commands/provider/cline-free.js +99 -99
  62. package/src/cli/commands/provider/cline-login.js +218 -218
  63. package/src/cli/commands/provider/cline-quota.js +65 -65
  64. package/src/cli/commands/provider/codearts-login.js +45 -45
  65. package/src/cli/commands/provider/config.js +119 -119
  66. package/src/cli/commands/provider/globalqwenwork-login.js +126 -126
  67. package/src/cli/commands/provider/keys-guard.js +50 -50
  68. package/src/cli/commands/provider/keys.js +161 -161
  69. package/src/cli/commands/provider/qoder-checkin.js +81 -81
  70. package/src/cli/commands/provider/qoder-login.js +102 -102
  71. package/src/cli/commands/provider/raccoon-login.js +180 -180
  72. package/src/cli/commands/provider/raccoon-password.js +162 -162
  73. package/src/cli/commands/provider/raccoon-quota.js +109 -109
  74. package/src/cli/commands/provider/traework-checkin.js +144 -144
  75. package/src/cli/commands/provider/traework-login.js +164 -164
  76. package/src/cli/commands/provider/workbuddy-login.js +153 -153
  77. package/src/cli/commands/provider/zcode-login.js +77 -77
  78. package/src/cli/commands/provider/zcode-quota.js +55 -55
  79. package/src/cli/commands/provider.js +1 -1
  80. package/src/cli/commands/stats.js +158 -149
  81. package/src/cli/commands/system.js +261 -261
  82. package/src/cli/commands/timezone.js +47 -47
  83. package/src/cli/commands/use-group.js +60 -60
  84. package/src/cli/commands/v6-pool.js +123 -0
  85. package/src/cli/commands/workbuddy.js +189 -189
  86. package/src/cli/format.js +141 -141
  87. package/src/cli/group-helpers.js +70 -70
  88. package/src/cli/help.js +80 -80
  89. package/src/cli/index.js +73 -70
  90. package/src/cli/interactive.js +121 -121
  91. package/src/cli/join-core.js +99 -99
  92. package/src/cli/join-wizard.js +175 -175
  93. package/src/cli/policy.js +136 -136
  94. package/src/cli/provider-row.js +126 -126
  95. package/src/cli/util.js +24 -24
  96. package/src/compat.js +53 -53
  97. package/src/events.js +38 -38
  98. package/src/free-lane.js +131 -131
  99. package/src/free-watcher.js +63 -63
  100. package/src/groups.js +262 -262
  101. package/src/logs.js +195 -195
  102. package/src/metrics.js +71 -62
  103. package/src/model-capabilities/context-lookup.js +65 -65
  104. package/src/model-capabilities/merge.js +142 -142
  105. package/src/model-trace.js +187 -187
  106. package/src/models.js +1 -1
  107. package/src/opencode-identity.js +72 -72
  108. package/src/peers.js +254 -254
  109. package/src/plugins.js +107 -107
  110. package/src/providers/AGENTS.md +49 -0
  111. package/src/providers/base.js +164 -164
  112. package/src/providers/classify.js +22 -22
  113. package/src/providers/cline/allowlist-sync.js +93 -93
  114. package/src/providers/cline/free-catalog.js +71 -71
  115. package/src/providers/cline/headers.js +37 -37
  116. package/src/providers/cline/index.js +130 -130
  117. package/src/providers/cline/models.js +150 -150
  118. package/src/providers/cline/usage.js +274 -274
  119. package/src/providers/codearts/auth-pool.js +148 -148
  120. package/src/providers/codearts/concurrency.js +34 -34
  121. package/src/providers/codearts/const.js +65 -65
  122. package/src/providers/codearts/dpop.js +95 -95
  123. package/src/providers/codearts/index.js +175 -175
  124. package/src/providers/codearts/login.js +214 -214
  125. package/src/providers/codearts/models.js +182 -182
  126. package/src/providers/codearts/sign.js +96 -96
  127. package/src/providers/codearts/sse.js +204 -204
  128. package/src/providers/codearts/stream.js +209 -209
  129. package/src/providers/codearts/sts.js +124 -124
  130. package/src/providers/codearts.js +1 -1
  131. package/src/providers/dispatcher.js +146 -146
  132. package/src/providers/generic.js +156 -156
  133. package/src/providers/globalqwenwork/account-store.js +141 -141
  134. package/src/providers/globalqwenwork/constants.js +73 -73
  135. package/src/providers/globalqwenwork/crypto.js +2 -2
  136. package/src/providers/globalqwenwork/http.js +2 -2
  137. package/src/providers/globalqwenwork/index.js +331 -331
  138. package/src/providers/globalqwenwork/payload.js +147 -147
  139. package/src/providers/globalqwenwork/rsa.js +2 -2
  140. package/src/providers/globalqwenwork/sse.js +2 -2
  141. package/src/providers/globalqwenwork/stream.js +2 -2
  142. package/src/providers/globalqwenwork.js +1 -1
  143. package/src/providers/globalworkbuddy/account-store.js +198 -198
  144. package/src/providers/globalworkbuddy/auth.js +97 -97
  145. package/src/providers/globalworkbuddy/catalog.js +138 -138
  146. package/src/providers/globalworkbuddy/constants.js +73 -73
  147. package/src/providers/globalworkbuddy/device-auth.js +120 -120
  148. package/src/providers/globalworkbuddy/errors.js +98 -98
  149. package/src/providers/globalworkbuddy/headers.js +89 -89
  150. package/src/providers/globalworkbuddy/models.js +96 -96
  151. package/src/providers/globalworkbuddy/payload.js +80 -80
  152. package/src/providers/index.js +5 -5
  153. package/src/providers/model-id.js +172 -172
  154. package/src/providers/opencode.js +19 -19
  155. package/src/providers/openrouter.js +31 -31
  156. package/src/providers/qoder/account-store.js +106 -106
  157. package/src/providers/qoder/aggregate.js +60 -60
  158. package/src/providers/qoder/baseprompt.json +591 -591
  159. package/src/providers/qoder/checkin.js +160 -160
  160. package/src/providers/qoder/constants.js +44 -44
  161. package/src/providers/qoder/encode.js +41 -41
  162. package/src/providers/qoder/fingerprint.js +38 -38
  163. package/src/providers/qoder/models.js +77 -77
  164. package/src/providers/qoder/oauth.js +84 -84
  165. package/src/providers/qoder/payload.js +162 -162
  166. package/src/providers/qoder/request.js +27 -27
  167. package/src/providers/qoder/session.js +134 -134
  168. package/src/providers/qoder/sticky.js +38 -38
  169. package/src/providers/qoder/stream.js +116 -116
  170. package/src/providers/qoder.js +1 -1
  171. package/src/providers/raccoon/account-store.js +216 -216
  172. package/src/providers/raccoon/auth.js +67 -67
  173. package/src/providers/raccoon/chat.js +228 -228
  174. package/src/providers/raccoon/const.js +180 -180
  175. package/src/providers/raccoon/credits.js +185 -185
  176. package/src/providers/raccoon/crypto.js +43 -43
  177. package/src/providers/raccoon/envelope.js +31 -31
  178. package/src/providers/raccoon/headers.js +45 -45
  179. package/src/providers/raccoon/index.js +216 -216
  180. package/src/providers/raccoon/login.js +279 -279
  181. package/src/providers/raccoon/models.js +160 -160
  182. package/src/providers/raccoon/password-login.js +102 -102
  183. package/src/providers/raccoon/qr-codewords.js +115 -115
  184. package/src/providers/raccoon/qr.js +236 -236
  185. package/src/providers/raccoon/sse.js +135 -135
  186. package/src/providers/responses-channel.js +120 -120
  187. package/src/providers/share-keys.js +53 -53
  188. package/src/providers/traework/account-store.js +234 -234
  189. package/src/providers/traework/chat.js +217 -217
  190. package/src/providers/traework/checkin.js +77 -77
  191. package/src/providers/traework/constants.js +41 -41
  192. package/src/providers/traework/errors.js +52 -52
  193. package/src/providers/traework/headers.js +79 -79
  194. package/src/providers/traework/index.js +132 -132
  195. package/src/providers/traework/market-identity.js +116 -116
  196. package/src/providers/traework/models.js +98 -98
  197. package/src/providers/traework/payload.js +86 -86
  198. package/src/providers/traework/sse.js +184 -184
  199. package/src/providers/traework/token.js +52 -52
  200. package/src/providers/traework.js +2 -2
  201. package/src/providers/workbuddy/account-store.js +186 -186
  202. package/src/providers/workbuddy/auth.js +158 -158
  203. package/src/providers/workbuddy/balance.js +86 -86
  204. package/src/providers/workbuddy/cat-travel.js +125 -125
  205. package/src/providers/workbuddy/checkin.js +75 -75
  206. package/src/providers/workbuddy/desktop-info.js +78 -78
  207. package/src/providers/workbuddy/growth-api.js +67 -67
  208. package/src/providers/workbuddy/growth-plans.js +63 -63
  209. package/src/providers/workbuddy/growth.js +181 -181
  210. package/src/providers/workbuddy/index.js +217 -217
  211. package/src/providers/workbuddy/models.js +126 -126
  212. package/src/providers/workbuddy/payload.js +141 -141
  213. package/src/providers/workbuddy/rotation-log.js +83 -83
  214. package/src/providers/workbuddy/sanitize-tools.js +61 -61
  215. package/src/providers/workbuddy/sdk-chat.js +26 -26
  216. package/src/providers/workbuddy-balance.js +78 -78
  217. package/src/providers/zcode/account-store.js +129 -129
  218. package/src/providers/zcode/auth.js +28 -28
  219. package/src/providers/zcode/chat.js +179 -179
  220. package/src/providers/zcode/const.js +54 -54
  221. package/src/providers/zcode/context-shape.js +162 -162
  222. package/src/providers/zcode/headers.js +93 -93
  223. package/src/providers/zcode/index.js +124 -124
  224. package/src/providers/zcode/models.js +65 -65
  225. package/src/providers/zcode/oauth.js +120 -120
  226. package/src/providers/zcode/quota.js +176 -176
  227. package/src/providers/zcode/sse.js +196 -196
  228. package/src/readline-compat.js +25 -25
  229. package/src/responses/translate.js +311 -311
  230. package/src/routes/AGENTS.md +37 -0
  231. package/src/routes/chat/broadband-handler.js +72 -72
  232. package/src/routes/chat/empty-turn-report.js +31 -31
  233. package/src/routes/chat/exhausted-handler.js +87 -87
  234. package/src/routes/chat/hedge-handler.js +130 -130
  235. package/src/routes/chat/index.js +1 -1
  236. package/src/routes/chat/local-handler.js +63 -63
  237. package/src/routes/chat/peer-handler.js +81 -81
  238. package/src/routes/chat.js +3 -3
  239. package/src/routes/fallback.js +88 -88
  240. package/src/routes/groups-relay.js +214 -214
  241. package/src/routes/groups.js +122 -122
  242. package/src/routes/helpers.js +93 -83
  243. package/src/routes/index.js +147 -147
  244. package/src/routes/messages-route.js +375 -365
  245. package/src/routes/peers.js +10 -2
  246. package/src/routes/relay-queue.js +181 -181
  247. package/src/routes/relay.js +60 -60
  248. package/src/routes/responses-route.js +213 -213
  249. package/src/routes/stream-body.js +84 -84
  250. package/src/routes/stream-hold.js +337 -337
  251. package/src/routes/stream-scan.js +366 -366
  252. package/src/routes.js +3 -3
  253. package/src/runtime/auto-update.js +113 -113
  254. package/src/runtime/broadband-stream.js +79 -79
  255. package/src/runtime/broadband.js +103 -103
  256. package/src/runtime/group-sync.js +81 -81
  257. package/src/runtime/keep-warm.js +120 -120
  258. package/src/runtime/lifecycle-forensics.js +116 -116
  259. package/src/runtime/lifecycle-log.js +87 -87
  260. package/src/runtime/log-stamp.js +73 -73
  261. package/src/runtime/proc-info.js +137 -137
  262. package/src/runtime/qoder-checkin.js +93 -93
  263. package/src/runtime/raccoon-checkin.js +126 -126
  264. package/src/runtime/server-lifecycle.js +189 -189
  265. package/src/runtime/traework-checkin.js +118 -118
  266. package/src/runtime/workbuddy-checkin.js +120 -120
  267. package/src/runtime/workbuddy-growth.js +133 -133
  268. package/src/state/facade.js +58 -58
  269. package/src/state/memory.js +162 -162
  270. package/src/state/merge.js +25 -25
  271. package/src/state/migrations/cline-unify.js +94 -94
  272. package/src/state/migrations.js +33 -33
  273. package/src/state/persist.js +42 -42
  274. package/src/state/provider-config.js +142 -142
  275. package/src/state/schemas/group.js +31 -31
  276. package/src/state/schemas/model.js +94 -94
  277. package/src/state/schemas/peer.js +31 -31
  278. package/src/state/schemas/port.js +10 -10
  279. package/src/state/schemas/provider.js +202 -202
  280. package/src/state/schemas/timezone.js +70 -70
  281. package/src/state/schemas/token.js +48 -48
  282. package/src/state/schemas/use-group.js +102 -102
  283. package/src/state/store.js +176 -176
  284. package/src/state.js +1 -1
  285. package/src/sync-claude-desktop.js +234 -234
  286. package/src/sync-claude.js +152 -152
  287. package/src/sync-codex.js +116 -116
  288. package/src/sync-workbuddy.js +158 -158
  289. package/src/talk-full-redact.js +129 -129
  290. package/src/talk-full.js +131 -131
  291. package/src/talk-log.js +257 -257
  292. package/src/text-tokens.js +52 -52
  293. package/src/thinking-effort.js +67 -67
  294. package/src/time.js +106 -106
  295. package/src/timeline.js +26 -26
  296. package/src/transport/index.js +1 -1
  297. package/src/upstream-engine/index.js +74 -74
  298. package/src/upstream-engine/mode.js +15 -15
  299. package/src/upstream-engine/sdk/attempt.js +221 -221
  300. package/src/upstream-engine/sdk/chat.js +28 -28
  301. package/src/upstream-engine/sdk/convert.js +192 -192
  302. package/src/upstream-engine/sdk/diagnose.js +62 -62
  303. package/src/upstream-engine/sdk/dispatch.js +55 -55
  304. package/src/upstream-engine/sdk/responses.js +176 -176
  305. package/src/upstream-engine/sdk/sse.js +166 -166
  306. package/src/upstream-responses.js +305 -305
  307. package/src/usage/cache-fields.js +58 -0
  308. package/src/usage/record.js +109 -106
  309. package/src/usage/report.js +209 -195
  310. package/src/v6pool/claim.js +157 -157
  311. package/src/v6pool/config.js +65 -65
  312. package/src/v6pool/detect.js +130 -130
  313. package/src/v6pool/index.js +152 -152
  314. package/src/v6pool/prefix.js +140 -140
  315. package/src/v6pool/probe.js +40 -40
  316. package/src/v6pool/report.js +104 -104
  317. package/src/v6pool/rotate.js +263 -263
  318. package/src/v6pool/selftest.js +154 -0
  319. package/src/warp-pool/config.js +74 -74
  320. package/src/warp-pool/health.js +106 -106
  321. package/src/warp-pool/identity.js +141 -141
  322. package/src/warp-pool/index.js +358 -358
  323. package/src/warp-pool/keys.js +29 -29
  324. package/src/warp-pool/nodes.js +95 -95
  325. package/src/warp-pool/ports.js +160 -160
  326. package/src/warp-pool/register.js +113 -113
  327. package/src/warp-pool/report.js +232 -232
  328. package/src/warp-pool/revive.js +168 -168
  329. package/src/warp-pool/rotate.js +214 -214
  330. package/src/warp-pool/strategy.js +43 -43
  331. package/src/warp-pool/verdict.js +69 -69
  332. package/src/warp-pool/wireproxy.js +162 -162
@@ -0,0 +1,37 @@
1
+ # src/routes — 转发链契约(改 relay / 流式 / 用量前必读)
2
+
3
+ > 从 `AGENTS.md` 下沉(2026-09-29 分层,就近生效)。改完跑:`node --test --test-concurrency=1 test/stream-*.test.js test/relay-*.test.js test/usage-*.test.js` + `npm run docs:check`。
4
+
5
+ ## 时间量测的两条口径(ADR-0039,最易被改错)
6
+
7
+ - **保护闸门的零点 = `relay()` 入口的 `t0`**(首帧超时 / stall / MAX_STREAM / keepalive 四类)。**禁止**把上报锚点接进闸门判定,否则上游排队时间会吃掉超时预算、误杀正在出字的慢模型。
8
+ - **上报时长(usage 行)的零点 = 本次上游尝试起点**(`serial-trial` 的 `tUp`,经 `attemptStartMs` 交进 relay);relay 入口算 `preflightMs = t0 − 锚点` 并随**全部 4 个 return 点**带出,`relay-pipeline` 落行时相加。锚点缺失/负值一律 0。
9
+ - 报表口径与 state 口径**彻底分离**:`recordModelStats`(→ `-status`/`-model stats` 的 EMA)仍吃转发入口相对值,勿"顺手统一"。
10
+ - 首字与总耗时同加一个常数 → `总 − 首字` 恒等 → 速度读数不变(已锁为单测)。
11
+ - usage 行**只增字段**演进(`src/usage/record.js` 拥有行形状);旧行缺字段 = 未知,不得当 0。
12
+ - 模型日志里 `relay-first-chunk ttfMs=` 是**转发入口相对值**(故意与报表不同源),排查时别混。
13
+
14
+ ## relay 的行为契约(既有测试锁死)
15
+
16
+ - **超时是显式字段 `timedOut`**,`status` 数值只是 HTTP 语义展示,不得再拿 status 当"是否可 failover"的信号。
17
+ - **注释帧(`: keepalive`)不算首块**:不解除闸门、不触发超时救回,但照常透传;能否安全 failover 看 `detail.wrotePayload`(=是否已**提交**模型产出,见下条留口)。
18
+ - 闸门到点后上游仍吐出真实数据 → **撤销超时判定照常转发**(cancel 是异步的,竞态窗口内已到达的数据是纯收益),记 `detail.recoveries`。
19
+ - 任一闸门到点且未写真实数据 → 回退候选;唯一/末位候选无 failover 去向 → 闸门放宽到 `MSLXDFF_LAST_CANDIDATE_TIMEOUT_MS`(默认 120s,0=不限),防误杀慢模型。
20
+ - **空转 200 判据单一真相** = `stream-scan.js:isEmptyTurnDetail`(`chatShaped===true` 且 `chars===0 && toolCalls===0` 且 `finish_reason ∉ {tool_calls, function_call}`);relay 侧「要不要封口」与 pipeline 5a0「要不要重拉」都必须用它,两处各写一份必然错拍。判空 → 转 failover,不记 auto 冷却(空转≠模型坏)。错误包络暂扣 `holdableChunk` 与非 chat 形状放行同在 `stream-scan.js`(**不在 stream.js**)。
21
+ - **空轮留口(ADR-0043)**:模型产出(正文/工具/**思考**)出现前,空 delta 帧与 `[DONE]` 一律暂扣不写(`stream-hold.js:createRevocablePrefix`);流结束仍零产出且未断下游 → **不 `res.end()`**,返回 `heldOpen:true` 交回重拉/换候选,正文续写同一条连接。三条铁律:① 异形/解析失败帧必须立即透传(`isTrivialFrame` 白名单式),透传契约不得为救空轮让路;② `[DONE]` 是终点,错误帧要排在它之前(tail 槽)且全程只允许一个;③ 重入同一条连接时 headers 可能已 flush → `statusCode`/`setHeader` 一律门控,收场经 `endHeldFailure`/`endEmptyTurnStream`/`helpers.json` 的 `writableEnded`+`headersSent` 守卫保证**恰好一次**,禁止把 JSON 体混进 `text/event-stream`。
22
+ - 断下游即掐上游:relay 自持 `reader`(`for-await` 会锁 ReadableStream 使 `body.cancel()` 必 reject),`reader.cancel()` 才真掐得断。
23
+
24
+ ## 组/组员与宽带中继(`src/routes/groups*.js`、`peers.js`、`relay-queue.js`)
25
+
26
+ - 慢模型不掐当前流:首块 25s 内到即视为可用、后续无限等;stall 15s 仅作质量分,耗时 >20s 降权;`MSLXDFF_HEDGE_DELAY_MS=1000` 时 1s 并发组员赛跑,赢家 `x-mslxdff-via`;指定模型 429 时按延迟 EMA 择优切最快模型。
27
+ - `SLOW_TOTAL_MS=20s` 标 slow 进 5 分钟冷却(普通 60s);上游重试 `1次×100ms`;`MSLXDFF_STREAM_TIMEOUT_MS=25s`(**仅首块未到且未写出任何字节时**才 failover;`0`=显式关闭首块超时,给重思考模型用);`MAX_STREAM_MS` **默认 0=关**(agent 长任务合法超 120s:实测 1.67% 的 relay 走 144s~449s 且完整收尾,默认设 120s 会腰斩它们;需要硬上限才显式设)。
28
+ - 宽带 `relay://` 成员走 `GET /v1/groups/relay/stream` SSE 单长连 25s ping 直推(`pushToStream`),`MSLXDFF_BROADBAND_STREAM=0` 回退 1s poll,`MSLXDFF_BROADBAND_PING_MS=25000`/`_STALE_MS=90000` 可配(空闲 -96%);用户明确反对 1s 轮询的空轮询。
29
+ - 组命令语义:`-group list` 序号按 state 成员顺序(过滤 leader)固定渲染,`-group remove <seq>` 用同一顺序踢人(仅组长);组长 `-leavegroup` 提示改用 `-delgroup`,`-delgroup` 在组员节点报错并提示该组由谁领导。
30
+ - 模型可观测面:事件用**黑名单** `TRACE_DENY`(默认全可见,只排噪声与敏感面);决定类事件只渲染 `DECISION_FIELDS` 登记过的标量,**"加日志"≠"倒数据"**,payload/正文一律不落;回显取头单一入口 `upstreamEcho(res)`。
31
+
32
+ ## Responses 端点(`routes/responses-route.js` + `responses/translate.js`,ADR-0012)
33
+
34
+ - 复用 ChatPipeline 全链路,只做形状翻译;stateless(`previous_response_id` 不支持);**思考档位已透传**:`reasoning.effort` → `reasoning_effort`(change fix-thinking-effort-passthrough / ADR-0053)。
35
+ - Codex Rust 严格解码三特例(缺一整轮作废):① 对 codex 调用者(UA/originator `codex_*` 或 `?client_version=`)`GET /v1/models` 追加顶层 `models: []`(**必须空**,填真目录会覆盖其内置 agent prompt);② completed 的 usage 转 Responses 口径(`input_tokens/output_tokens` + details);③ done 事件 `text` 带累积全文。
36
+ - `-setto chatgpt`(`src/sync-codex.js`)写三端共用 `~/.codex/config.toml`(`wire_api="responses"` 显式、鉴权绝对路径 `node + -showtoken`,换模型需重跑 setto / `codex exec -m` / 手改 `model=`,单键覆盖无需剪枝);排障 `MSLXDFF_RESPONSES_DEBUG=1` 看 `daemon.log [responses]` 四段 + Codex 侧 `~/.codex/logs_2.sqlite`(`Request completed ... status=`)与 `thread_history_1.sqlite`(正文)。
37
+ - `@ai-sdk/openai` 只是客户端包,替代不了服务端翻译层;Qwen 上游走 `/v1/messages`(Anthropic 协议),接 Codex 要多一跳(未做)。
@@ -1,72 +1,72 @@
1
- import { relay, SLOW_TOTAL_MS, STREAM_TIMEOUT_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS } from "../stream.js";
2
- import { buildFallbackInfo } from "../fallback.js";
3
- import { createRelayPipeline } from "./relay-pipeline.js";
4
- import { tryBroadbandRelay } from "../relay-queue.js";
5
-
6
- /** 薄适配:broadband 两种形态合一经由 pipeline */
7
- export async function handleBroadbandRelay({
8
- model,
9
- body,
10
- hops,
11
- lastErr,
12
- requested,
13
- useAuto,
14
- lockModel,
15
- auto,
16
- groups,
17
- token,
18
- bus,
19
- logs,
20
- handlerCtx,
21
- evt,
22
- mark,
23
- perf0,
24
- attemptStartMs,
25
- stages,
26
- res,
27
- startedAt,
28
- plugins,
29
- deps = {},
30
- }) {
31
- const { createPipeline = createRelayPipeline, tryBroadbandRelay: tryRelay = tryBroadbandRelay } = deps;
32
- const bb = await tryRelay({ groups, token, model, body, hops, bus, logs, reqId: handlerCtx.reqId, evt, res, mark, perf0, stages });
33
- if (!bb) {
34
- evt("relay-miss", { reqId: handlerCtx.reqId, model });
35
- return { handled: false };
36
- }
37
- const pipeline = createPipeline({
38
- relay,
39
- buildFallbackInfo,
40
- auto,
41
- plugins,
42
- evt,
43
- mark,
44
- logCall: () => {},
45
- logError: () => {},
46
- constants: { STREAM_TIMEOUT_MS, SLOW_TOTAL_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS },
47
- startedAt,
48
- stages,
49
- });
50
- const isResponse = bb.result && typeof bb.result.status === "number" && typeof bb.result.headers?.get === "function";
51
- if (isResponse) {
52
- const r = await pipeline.execute({ res, upRes: bb.result, body, requested, actual: model, lastErr, via: "broadband", lockModel, useAuto, handlerCtx, mark, perf0, attemptStartMs, stages, startedAt });
53
- if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
54
- return { handled: true };
55
- }
56
- if (bb.result && typeof bb.result.status === "number") {
57
- const b = bb.result.body || "";
58
- const str = typeof b === "string" ? b : JSON.stringify(b);
59
- const isSSE = bb.result.headers?.["Content-Type"]?.includes("text/event-stream");
60
- const fakeRes = {
61
- status: bb.result.status,
62
- headers: { get: (k) => bb.result.headers?.[k] || bb.result.headers?.[k.toLowerCase()] || null },
63
- text: async () => str,
64
- body: isSSE ? (async function* () { yield Buffer.from(str); })() : null,
65
- };
66
- const r = await pipeline.execute({ res, upRes: fakeRes, body, requested, actual: model, lastErr, via: "broadband-local", lockModel, useAuto, handlerCtx, mark, perf0, attemptStartMs, stages, startedAt });
67
- if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
68
- return { handled: true };
69
- }
70
- evt("relay-miss", { reqId: handlerCtx.reqId, model });
71
- return { handled: false };
72
- }
1
+ import { relay, SLOW_TOTAL_MS, STREAM_TIMEOUT_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS } from "../stream.js";
2
+ import { buildFallbackInfo } from "../fallback.js";
3
+ import { createRelayPipeline } from "./relay-pipeline.js";
4
+ import { tryBroadbandRelay } from "../relay-queue.js";
5
+
6
+ /** 薄适配:broadband 两种形态合一经由 pipeline */
7
+ export async function handleBroadbandRelay({
8
+ model,
9
+ body,
10
+ hops,
11
+ lastErr,
12
+ requested,
13
+ useAuto,
14
+ lockModel,
15
+ auto,
16
+ groups,
17
+ token,
18
+ bus,
19
+ logs,
20
+ handlerCtx,
21
+ evt,
22
+ mark,
23
+ perf0,
24
+ attemptStartMs,
25
+ stages,
26
+ res,
27
+ startedAt,
28
+ plugins,
29
+ deps = {},
30
+ }) {
31
+ const { createPipeline = createRelayPipeline, tryBroadbandRelay: tryRelay = tryBroadbandRelay } = deps;
32
+ const bb = await tryRelay({ groups, token, model, body, hops, bus, logs, reqId: handlerCtx.reqId, evt, res, mark, perf0, stages });
33
+ if (!bb) {
34
+ evt("relay-miss", { reqId: handlerCtx.reqId, model });
35
+ return { handled: false };
36
+ }
37
+ const pipeline = createPipeline({
38
+ relay,
39
+ buildFallbackInfo,
40
+ auto,
41
+ plugins,
42
+ evt,
43
+ mark,
44
+ logCall: () => {},
45
+ logError: () => {},
46
+ constants: { STREAM_TIMEOUT_MS, SLOW_TOTAL_MS, STALL_TIMEOUT_MS, SCORE_STALL_MS },
47
+ startedAt,
48
+ stages,
49
+ });
50
+ const isResponse = bb.result && typeof bb.result.status === "number" && typeof bb.result.headers?.get === "function";
51
+ if (isResponse) {
52
+ const r = await pipeline.execute({ res, upRes: bb.result, body, requested, actual: model, lastErr, via: "broadband", lockModel, useAuto, handlerCtx, mark, perf0, attemptStartMs, stages, startedAt });
53
+ if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
54
+ return { handled: true };
55
+ }
56
+ if (bb.result && typeof bb.result.status === "number") {
57
+ const b = bb.result.body || "";
58
+ const str = typeof b === "string" ? b : JSON.stringify(b);
59
+ const isSSE = bb.result.headers?.["Content-Type"]?.includes("text/event-stream");
60
+ const fakeRes = {
61
+ status: bb.result.status,
62
+ headers: { get: (k) => bb.result.headers?.[k] || bb.result.headers?.[k.toLowerCase()] || null },
63
+ text: async () => str,
64
+ body: isSSE ? (async function* () { yield Buffer.from(str); })() : null,
65
+ };
66
+ const r = await pipeline.execute({ res, upRes: fakeRes, body, requested, actual: model, lastErr, via: "broadband-local", lockModel, useAuto, handlerCtx, mark, perf0, attemptStartMs, stages, startedAt });
67
+ if (!r.handled) return { handled: false, upRes: null, lastErr: r.lastErr };
68
+ return { handled: true };
69
+ }
70
+ evt("relay-miss", { reqId: handlerCtx.reqId, model });
71
+ return { handled: false };
72
+ }
@@ -1,31 +1,31 @@
1
- // 空转 200 的收场记账(从 relay-pipeline.js 抽出:那儿是 20KB 硬门的存量超标文件,先拆后写)。
2
- // 只做「把这一轮判成空转后该记什么、该把 lastErr 造成什么形状」,不碰网络/闸门/响应体。
3
- // 判据本身不在这里:单一真相是 stream-scan.js 的 isEmptyTurnDetail(relay 侧「要不要封口」与
4
- // 这里「要不要重拉」必须同源,否则会出现留了口却不再救、或已终结却仍重拉的错拍)。
5
- import { upstreamEcho } from "../../model-trace.js";
6
-
7
- /**
8
- * 空转 200 收场:记 errors.log + upstream-error/fallback 事件,返回交给 serial-trial 的 lastErr。
9
- * @param {{ out: object, actual: string, reqId: string, upRes: object, logError: Function, evt: Function }} p
10
- * @returns {{ handled: false, upRes: null, lastErr: object }}
11
- */
12
- export function reportEmptyTurn({ out, actual, reqId, upRes, logError, evt }) {
13
- const d = out.detail || {};
14
- const why = d.sawFinishReason ? ` (finish_reason=${d.sawFinishReason})` : " (no content, no tool calls)";
15
- // 错误包络暂扣后下游不再直观看到上游原文:把摘要带进最终报错(截断 200 字),排障不断线。
16
- const err = d.upstreamErrorText ? ` upstream=${String(d.upstreamErrorText).slice(0, 200)}` : "";
17
- // 零正文的成因直接写进报错(不只给现象):finish_reason=length + 大量思考 = 额度被 reasoning 吃光
18
- // (实测 cline-free deepseek reasoningChars=28151、completion_tokens=8192=max_tokens、chars=0)。
19
- // 同参重拉必复现,故 serial-trial 的空转重试会顺手把 max_tokens 抬一档(见 emptyRaiseCap)。
20
- const lenCapped = d.sawFinishReason === "length";
21
- const hint = lenCapped && (Number(d.reasoningChars) || 0) > 0
22
- ? ` [思考 ${Number(d.reasoningChars) || 0} 字刷满 max_tokens,正文 0 字]`
23
- : "";
24
- try { logError(actual, 502, `empty turn${why}${err}${hint}`); } catch {}
25
- evt("upstream-error", { reqId, model: actual, status: 502, message: "empty turn", timing: null, finish: d.sawFinishReason || null, reasoningChars: Number(d.reasoningChars) || 0, wroteFrames: Number(d.wroteChunks) || 0, heldOpen: out.heldOpen === true, ...upstreamEcho(upRes) });
26
- evt("fallback", { reqId, from: actual, to: null, reason: "empty turn" });
27
- let errMsg = `EMPTY_MODEL_RESPONSE: upstream returned 200 with no content${why}${err}${hint} — retry or rephrase`;
28
- // 上游报了 retryAfterSeconds:原样透出(客户端等得起),别被我们的包装话术盖掉。
29
- if (d.upstreamErrorText && d.upstreamErrorText.includes("retryAfterSeconds")) errMsg = d.upstreamErrorText;
30
- return { handled: false, upRes: null, lastErr: { model: actual, upstream: null, status: 502, message: errMsg, emptyTurn: true, heldOpen: out.heldOpen === true } };
31
- }
1
+ // 空转 200 的收场记账(从 relay-pipeline.js 抽出:那儿是 20KB 硬门的存量超标文件,先拆后写)。
2
+ // 只做「把这一轮判成空转后该记什么、该把 lastErr 造成什么形状」,不碰网络/闸门/响应体。
3
+ // 判据本身不在这里:单一真相是 stream-scan.js 的 isEmptyTurnDetail(relay 侧「要不要封口」与
4
+ // 这里「要不要重拉」必须同源,否则会出现留了口却不再救、或已终结却仍重拉的错拍)。
5
+ import { upstreamEcho } from "../../model-trace.js";
6
+
7
+ /**
8
+ * 空转 200 收场:记 errors.log + upstream-error/fallback 事件,返回交给 serial-trial 的 lastErr。
9
+ * @param {{ out: object, actual: string, reqId: string, upRes: object, logError: Function, evt: Function }} p
10
+ * @returns {{ handled: false, upRes: null, lastErr: object }}
11
+ */
12
+ export function reportEmptyTurn({ out, actual, reqId, upRes, logError, evt }) {
13
+ const d = out.detail || {};
14
+ const why = d.sawFinishReason ? ` (finish_reason=${d.sawFinishReason})` : " (no content, no tool calls)";
15
+ // 错误包络暂扣后下游不再直观看到上游原文:把摘要带进最终报错(截断 200 字),排障不断线。
16
+ const err = d.upstreamErrorText ? ` upstream=${String(d.upstreamErrorText).slice(0, 200)}` : "";
17
+ // 零正文的成因直接写进报错(不只给现象):finish_reason=length + 大量思考 = 额度被 reasoning 吃光
18
+ // (实测 cline-free deepseek reasoningChars=28151、completion_tokens=8192=max_tokens、chars=0)。
19
+ // 同参重拉必复现,故 serial-trial 的空转重试会顺手把 max_tokens 抬一档(见 emptyRaiseCap)。
20
+ const lenCapped = d.sawFinishReason === "length";
21
+ const hint = lenCapped && (Number(d.reasoningChars) || 0) > 0
22
+ ? ` [思考 ${Number(d.reasoningChars) || 0} 字刷满 max_tokens,正文 0 字]`
23
+ : "";
24
+ try { logError(actual, 502, `empty turn${why}${err}${hint}`); } catch {}
25
+ evt("upstream-error", { reqId, model: actual, status: 502, message: "empty turn", timing: null, finish: d.sawFinishReason || null, reasoningChars: Number(d.reasoningChars) || 0, wroteFrames: Number(d.wroteChunks) || 0, heldOpen: out.heldOpen === true, ...upstreamEcho(upRes) });
26
+ evt("fallback", { reqId, from: actual, to: null, reason: "empty turn" });
27
+ let errMsg = `EMPTY_MODEL_RESPONSE: upstream returned 200 with no content${why}${err}${hint} — retry or rephrase`;
28
+ // 上游报了 retryAfterSeconds:原样透出(客户端等得起),别被我们的包装话术盖掉。
29
+ if (d.upstreamErrorText && d.upstreamErrorText.includes("retryAfterSeconds")) errMsg = d.upstreamErrorText;
30
+ return { handled: false, upRes: null, lastErr: { model: actual, upstream: null, status: 502, message: errMsg, emptyTurn: true, heldOpen: out.heldOpen === true } };
31
+ }
@@ -1,87 +1,87 @@
1
- // Note: 失败收尾四分支在 result 后补 client-response,与成功路径对账口径一致(result 条数 == client-response 条数)— 见 .agents/notes/implemented/bug-fix/2026-09-25-sdk-headers-timeout-and-failure-client-response.md
2
- import { relay } from "../stream.js";
3
- import { endHeldFailure } from "../stream-hold.js"; // 终局收场出口(502 JSON 或 SSE 错误帧 + [DONE])
4
- import { json } from "../helpers.js";
5
- import { performance } from "node:perf_hooks";
6
- import { recordRelayTalk } from "../../talk-log.js"; // 环形对话日志:exhausted 收尾直连 relay,落盘点必须自己补一份
7
- import { recordAgentLoop } from "../../talk-full.js"; // agent 回路全量语料(talk/full):与 talk.log 并排各调一次,见 openspec add-agent-loop-capture D1
8
- import { captureTalkFlush } from "../stream-scan.js"; // 收场回收缺尾换行的末行,record* 读桶前调用(openspec fix-talk-capture-frame-loss D4)
9
-
10
- // 最后一站的收场:relay 的「不写响应」路径(超时 / 空轮延后封口)都汇到这里,没有上层 failover 了,
11
- // 必须把响应「恰好一次」地关掉。形状由 endHeldFailure 按 headers 是否已 flush 决定
12
- // (未 flush → 502 JSON;已 flush 的 SSE → 错误帧 + [DONE]),这里不再自己 json() 打已 flush 的流。
13
- function terminal(res, message) {
14
- endHeldFailure(res, message || "all auto models failed");
15
- }
16
-
17
- export async function handleExhaustedLocal({ res, body, lastErr, order, handlerCtx, evt, logCall, mark, perf0, stages, done, requested, useAuto, deps = {} }) {
18
- const { relay: relayImpl = relay } = deps;
19
- const model = lastErr?.model ?? handlerCtx.model;
20
- evt("exhausted-local", { reqId: handlerCtx.reqId, lastModel: lastErr?.model ?? model, lastStatus: lastErr?.status ?? 502, order });
21
- logCall(lastErr?.model ?? model, lastErr?.status ?? 502);
22
- if (lastErr?.upstream) {
23
- evt("relay-start", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-exhausted", isStream: Boolean(body.stream) });
24
- const out = await relayImpl(res, lastErr.upstream, body, {
25
- onFirstChunk: (d) => mark(`ttf-${lastErr.model}`),
26
- onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
27
- });
28
- // 收场回收(须在 relay-done 事件快照 detail 之前,review P2-6):flush 缺尾换行的末行,语料与线上字节对齐(fix-talk-capture-frame-loss D4)
29
- try { captureTalkFlush(out?.detail?.talk); } catch {}
30
- evt("relay-done", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-exhausted", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
31
- // 绕过 relay-pipeline 的最后一站:对话日志照常落盘(正文只进 talk/*.log,不进 events.log)
32
- // 本收尾点没有 attemptStartMs 可取(绕过 relay-pipeline 直连 relay)→ 不传 attemptMs,按回退路径走:
33
- // elapsed=relay 内部计时(与 relayMs 同值,spec 降级 scenario);finishReason 传 out.detail.sawFinishReason 与 3.3 同源。
34
- try { recordRelayTalk({ reqId: handlerCtx.reqId, api: handlerCtx?.api, model: lastErr.model, via: "local-exhausted", body, out, finishReason: out.detail?.sawFinishReason }); } catch {}
35
- // 回路语料另起一条独立 try:talk.log 是人读环形稿、capture 是机读全量语料,两者生死独立。
36
- // hops 走 handlerCtx.hops(serial-trial 以 {...handlerCtx} 展开传进来,与 reqId 同源);缺值时不兜 0 ——
37
- // 交给 talk-full 落 null(「hops 未知」在语料里看得见,比伪造成 0 诚实),组员流量拦截靠真值。
38
- try { recordAgentLoop({ reqId: handlerCtx.reqId, api: handlerCtx?.api, model: lastErr.model, via: "local-exhausted", hops: handlerCtx?.hops, body, out, sessionKey: handlerCtx?.sessionId, clientIp: handlerCtx?.clientIp }); } catch {} // attemptMs 缺省 → 回退 out.totalMs;缺 echo 的既有形状本期不补(tasks 3.4)
39
- evt("result", { reqId: handlerCtx.reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
40
- evt("client-response", { requested, actual: lastErr.model, via: "local", fallback: false, status: out.status, reqId: handlerCtx.reqId });
41
- // 最后一站:relay 超时/空轮留口路径都不写响应,这里没有上层,必须自己收尾
42
- if (out.timedOut || out.heldOpen) {
43
- terminal(res, out.detail?.exitReason === "empty-turn-hold"
44
- ? "EMPTY_MODEL_RESPONSE: 空轮重拉与换候选全部用尽,仍无正文"
45
- : out.detail?.upstreamError ? `upstream error: ${out.detail.upstreamError}` : `stream timed out after ${out.totalMs}ms`);
46
- }
47
- return true;
48
- }
49
- evt("result", { reqId: handlerCtx.reqId, model, status: lastErr?.status ?? 502, via: "none", timing: null });
50
- evt("client-response", { requested, actual: model, via: "none", fallback: false, status: lastErr?.status ?? 502, reqId: handlerCtx.reqId });
51
- done({ via: "none", status: lastErr?.status ?? 502, error: lastErr?.message || "all auto models failed" });
52
- terminal(res, lastErr?.message);
53
- return true;
54
- }
55
-
56
- export async function handleExhaustedAll({ res, body, lastErr, order, requested, handlerCtx, evt, logCall, mark, perf0, stages, deps = {} }) {
57
- const { relay: relayImpl = relay } = deps;
58
- evt("exhausted-all", { reqId: handlerCtx.reqId, lastModel: lastErr?.model ?? requested, lastStatus: lastErr?.status ?? 502, order });
59
- logCall(lastErr?.model ?? requested, lastErr?.status ?? 502);
60
- if (lastErr?.upstream) {
61
- evt("relay-start", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-final", isStream: Boolean(body.stream) });
62
- const out = await relayImpl(res, lastErr.upstream, body, {
63
- onFirstChunk: (d) => mark(`ttf-${lastErr.model}`),
64
- onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
65
- });
66
- // 收场回收:与 local-exhausted 同理,须在 relay-done 事件快照 detail 之前(fix-talk-capture-frame-loss D4 / review P2-6)
67
- try { captureTalkFlush(out?.detail?.talk); } catch {}
68
- evt("relay-done", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-final", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
69
- // 同上:全链耗尽的最终转发也要留下问答原文
70
- // 同上:本收尾点没有 attemptStartMs 可取(relay 直连、无尝试起点)→ 不传 attemptMs,
71
- // recordRelayTalk/recordAgentLoop 内部按回退路径走 elapsed=relay 计时(与 relayMs 同值,spec 降级 scenario);finishReason 同源传 out.detail.sawFinishReason。
72
- try { recordRelayTalk({ reqId: handlerCtx.reqId, api: handlerCtx?.api, model: lastErr.model, via: "local-final", body, out, finishReason: out.detail?.sawFinishReason }); } catch {}
73
- // 同上:全链耗尽的最终转发也要进语料(这条往往是「客户端只看到 502」时唯一的现场材料)
74
- try { recordAgentLoop({ reqId: handlerCtx.reqId, api: handlerCtx?.api, model: lastErr.model, via: "local-final", hops: handlerCtx?.hops, body, out, sessionKey: handlerCtx?.sessionId, clientIp: handlerCtx?.clientIp }); } catch {} // attemptMs 缺省 → 回退 out.totalMs(见上行注释;缺 echo 的形状本期不补)
75
- evt("result", { reqId: handlerCtx.reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
76
- evt("client-response", { requested, actual: lastErr.model, via: "local", fallback: false, status: out.status, reqId: handlerCtx.reqId });
77
- // 最后一站:relay 超时/空轮留口路径不写响应,这里没有上层 failover,必须自己收尾
78
- if (out.timedOut || out.heldOpen) {
79
- terminal(res, out.detail?.upstreamError ? `upstream error: ${out.detail.upstreamError}` : `stream timed out after ${out.totalMs}ms`);
80
- }
81
- return true;
82
- }
83
- evt("result", { reqId: handlerCtx.reqId, model: lastErr?.model ?? requested, status: lastErr?.status ?? 502, via: "none", timing: null });
84
- evt("client-response", { requested, actual: lastErr?.model ?? requested, via: "none", fallback: false, status: lastErr?.status ?? 502, reqId: handlerCtx.reqId });
85
- terminal(res, lastErr?.message);
86
- return true;
87
- }
1
+ // Note: 失败收尾四分支在 result 后补 client-response,与成功路径对账口径一致(result 条数 == client-response 条数)— 见 .agents/notes/implemented/bug-fix/2026-09-25-sdk-headers-timeout-and-failure-client-response.md
2
+ import { relay } from "../stream.js";
3
+ import { endHeldFailure } from "../stream-hold.js"; // 终局收场出口(502 JSON 或 SSE 错误帧 + [DONE])
4
+ import { json } from "../helpers.js";
5
+ import { performance } from "node:perf_hooks";
6
+ import { recordRelayTalk } from "../../talk-log.js"; // 环形对话日志:exhausted 收尾直连 relay,落盘点必须自己补一份
7
+ import { recordAgentLoop } from "../../talk-full.js"; // agent 回路全量语料(talk/full):与 talk.log 并排各调一次,见 openspec add-agent-loop-capture D1
8
+ import { captureTalkFlush } from "../stream-scan.js"; // 收场回收缺尾换行的末行,record* 读桶前调用(openspec fix-talk-capture-frame-loss D4)
9
+
10
+ // 最后一站的收场:relay 的「不写响应」路径(超时 / 空轮延后封口)都汇到这里,没有上层 failover 了,
11
+ // 必须把响应「恰好一次」地关掉。形状由 endHeldFailure 按 headers 是否已 flush 决定
12
+ // (未 flush → 502 JSON;已 flush 的 SSE → 错误帧 + [DONE]),这里不再自己 json() 打已 flush 的流。
13
+ function terminal(res, message) {
14
+ endHeldFailure(res, message || "all auto models failed");
15
+ }
16
+
17
+ export async function handleExhaustedLocal({ res, body, lastErr, order, handlerCtx, evt, logCall, mark, perf0, stages, done, requested, useAuto, deps = {} }) {
18
+ const { relay: relayImpl = relay } = deps;
19
+ const model = lastErr?.model ?? handlerCtx.model;
20
+ evt("exhausted-local", { reqId: handlerCtx.reqId, lastModel: lastErr?.model ?? model, lastStatus: lastErr?.status ?? 502, order });
21
+ logCall(lastErr?.model ?? model, lastErr?.status ?? 502);
22
+ if (lastErr?.upstream) {
23
+ evt("relay-start", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-exhausted", isStream: Boolean(body.stream) });
24
+ const out = await relayImpl(res, lastErr.upstream, body, {
25
+ onFirstChunk: (d) => mark(`ttf-${lastErr.model}`),
26
+ onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
27
+ });
28
+ // 收场回收(须在 relay-done 事件快照 detail 之前,review P2-6):flush 缺尾换行的末行,语料与线上字节对齐(fix-talk-capture-frame-loss D4)
29
+ try { captureTalkFlush(out?.detail?.talk); } catch {}
30
+ evt("relay-done", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-exhausted", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
31
+ // 绕过 relay-pipeline 的最后一站:对话日志照常落盘(正文只进 talk/*.log,不进 events.log)
32
+ // 本收尾点没有 attemptStartMs 可取(绕过 relay-pipeline 直连 relay)→ 不传 attemptMs,按回退路径走:
33
+ // elapsed=relay 内部计时(与 relayMs 同值,spec 降级 scenario);finishReason 传 out.detail.sawFinishReason 与 3.3 同源。
34
+ try { recordRelayTalk({ reqId: handlerCtx.reqId, api: handlerCtx?.api, model: lastErr.model, via: "local-exhausted", body, out, finishReason: out.detail?.sawFinishReason }); } catch {}
35
+ // 回路语料另起一条独立 try:talk.log 是人读环形稿、capture 是机读全量语料,两者生死独立。
36
+ // hops 走 handlerCtx.hops(serial-trial 以 {...handlerCtx} 展开传进来,与 reqId 同源);缺值时不兜 0 ——
37
+ // 交给 talk-full 落 null(「hops 未知」在语料里看得见,比伪造成 0 诚实),组员流量拦截靠真值。
38
+ try { recordAgentLoop({ reqId: handlerCtx.reqId, api: handlerCtx?.api, model: lastErr.model, via: "local-exhausted", hops: handlerCtx?.hops, body, out, sessionKey: handlerCtx?.sessionId, clientIp: handlerCtx?.clientIp }); } catch {} // attemptMs 缺省 → 回退 out.totalMs;缺 echo 的既有形状本期不补(tasks 3.4)
39
+ evt("result", { reqId: handlerCtx.reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
40
+ evt("client-response", { requested, actual: lastErr.model, via: "local", fallback: false, status: out.status, reqId: handlerCtx.reqId });
41
+ // 最后一站:relay 超时/空轮留口路径都不写响应,这里没有上层,必须自己收尾
42
+ if (out.timedOut || out.heldOpen) {
43
+ terminal(res, out.detail?.exitReason === "empty-turn-hold"
44
+ ? "EMPTY_MODEL_RESPONSE: 空轮重拉与换候选全部用尽,仍无正文"
45
+ : out.detail?.upstreamError ? `upstream error: ${out.detail.upstreamError}` : `stream timed out after ${out.totalMs}ms`);
46
+ }
47
+ return true;
48
+ }
49
+ evt("result", { reqId: handlerCtx.reqId, model, status: lastErr?.status ?? 502, via: "none", timing: null });
50
+ evt("client-response", { requested, actual: model, via: "none", fallback: false, status: lastErr?.status ?? 502, reqId: handlerCtx.reqId });
51
+ done({ via: "none", status: lastErr?.status ?? 502, error: lastErr?.message || "all auto models failed" });
52
+ terminal(res, lastErr?.message);
53
+ return true;
54
+ }
55
+
56
+ export async function handleExhaustedAll({ res, body, lastErr, order, requested, handlerCtx, evt, logCall, mark, perf0, stages, deps = {} }) {
57
+ const { relay: relayImpl = relay } = deps;
58
+ evt("exhausted-all", { reqId: handlerCtx.reqId, lastModel: lastErr?.model ?? requested, lastStatus: lastErr?.status ?? 502, order });
59
+ logCall(lastErr?.model ?? requested, lastErr?.status ?? 502);
60
+ if (lastErr?.upstream) {
61
+ evt("relay-start", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-final", isStream: Boolean(body.stream) });
62
+ const out = await relayImpl(res, lastErr.upstream, body, {
63
+ onFirstChunk: (d) => mark(`ttf-${lastErr.model}`),
64
+ onDownstreamAbort: () => evt("client-abort", { reqId: handlerCtx.reqId, model: lastErr.model, totalMs: Math.round(performance.now() - perf0), stages: [...stages] }),
65
+ });
66
+ // 收场回收:与 local-exhausted 同理,须在 relay-done 事件快照 detail 之前(fix-talk-capture-frame-loss D4 / review P2-6)
67
+ try { captureTalkFlush(out?.detail?.talk); } catch {}
68
+ evt("relay-done", { reqId: handlerCtx.reqId, model: lastErr.model, via: "local-final", status: out.status, ttfMs: out.ttfMs, totalMs: out.totalMs, aborted: out.aborted, interrupted: out.interrupted ?? false, detail: out.detail ?? null });
69
+ // 同上:全链耗尽的最终转发也要留下问答原文
70
+ // 同上:本收尾点没有 attemptStartMs 可取(relay 直连、无尝试起点)→ 不传 attemptMs,
71
+ // recordRelayTalk/recordAgentLoop 内部按回退路径走 elapsed=relay 计时(与 relayMs 同值,spec 降级 scenario);finishReason 同源传 out.detail.sawFinishReason。
72
+ try { recordRelayTalk({ reqId: handlerCtx.reqId, api: handlerCtx?.api, model: lastErr.model, via: "local-final", body, out, finishReason: out.detail?.sawFinishReason }); } catch {}
73
+ // 同上:全链耗尽的最终转发也要进语料(这条往往是「客户端只看到 502」时唯一的现场材料)
74
+ try { recordAgentLoop({ reqId: handlerCtx.reqId, api: handlerCtx?.api, model: lastErr.model, via: "local-final", hops: handlerCtx?.hops, body, out, sessionKey: handlerCtx?.sessionId, clientIp: handlerCtx?.clientIp }); } catch {} // attemptMs 缺省 → 回退 out.totalMs(见上行注释;缺 echo 的形状本期不补)
75
+ evt("result", { reqId: handlerCtx.reqId, model: lastErr.model, status: out.status, via: "local", timing: lastErr.upstream._t ?? null, ttfMs: out.ttfMs, totalMs: out.totalMs, detail: out.detail ?? null });
76
+ evt("client-response", { requested, actual: lastErr.model, via: "local", fallback: false, status: out.status, reqId: handlerCtx.reqId });
77
+ // 最后一站:relay 超时/空轮留口路径不写响应,这里没有上层 failover,必须自己收尾
78
+ if (out.timedOut || out.heldOpen) {
79
+ terminal(res, out.detail?.upstreamError ? `upstream error: ${out.detail.upstreamError}` : `stream timed out after ${out.totalMs}ms`);
80
+ }
81
+ return true;
82
+ }
83
+ evt("result", { reqId: handlerCtx.reqId, model: lastErr?.model ?? requested, status: lastErr?.status ?? 502, via: "none", timing: null });
84
+ evt("client-response", { requested, actual: lastErr?.model ?? requested, via: "none", fallback: false, status: lastErr?.status ?? 502, reqId: handlerCtx.reqId });
85
+ terminal(res, lastErr?.message);
86
+ return true;
87
+ }