mslxdff 0.1.203 → 0.1.207

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 (330) hide show
  1. package/README.md +3 -3
  2. package/bin/mslxdff.js +1 -1
  3. package/docs/cli_help_mini.md +146 -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/workbuddy.js +189 -189
  85. package/src/cli/format.js +141 -141
  86. package/src/cli/group-helpers.js +70 -70
  87. package/src/cli/help.js +80 -80
  88. package/src/cli/index.js +70 -70
  89. package/src/cli/interactive.js +121 -121
  90. package/src/cli/join-core.js +99 -99
  91. package/src/cli/join-wizard.js +175 -175
  92. package/src/cli/policy.js +136 -136
  93. package/src/cli/provider-row.js +126 -126
  94. package/src/cli/util.js +24 -24
  95. package/src/compat.js +53 -53
  96. package/src/events.js +38 -38
  97. package/src/free-lane.js +131 -131
  98. package/src/free-watcher.js +63 -63
  99. package/src/groups.js +262 -262
  100. package/src/logs.js +195 -195
  101. package/src/metrics.js +71 -62
  102. package/src/model-capabilities/context-lookup.js +65 -65
  103. package/src/model-capabilities/merge.js +142 -142
  104. package/src/model-trace.js +187 -187
  105. package/src/models.js +1 -1
  106. package/src/opencode-identity.js +72 -72
  107. package/src/peers.js +254 -254
  108. package/src/plugins.js +107 -107
  109. package/src/providers/AGENTS.md +49 -0
  110. package/src/providers/base.js +164 -164
  111. package/src/providers/classify.js +22 -22
  112. package/src/providers/cline/allowlist-sync.js +93 -93
  113. package/src/providers/cline/free-catalog.js +71 -71
  114. package/src/providers/cline/headers.js +37 -37
  115. package/src/providers/cline/index.js +130 -130
  116. package/src/providers/cline/models.js +150 -150
  117. package/src/providers/cline/usage.js +274 -274
  118. package/src/providers/codearts/auth-pool.js +148 -148
  119. package/src/providers/codearts/concurrency.js +34 -34
  120. package/src/providers/codearts/const.js +65 -65
  121. package/src/providers/codearts/dpop.js +95 -95
  122. package/src/providers/codearts/index.js +175 -175
  123. package/src/providers/codearts/login.js +214 -214
  124. package/src/providers/codearts/models.js +182 -182
  125. package/src/providers/codearts/sign.js +96 -96
  126. package/src/providers/codearts/sse.js +204 -204
  127. package/src/providers/codearts/stream.js +209 -209
  128. package/src/providers/codearts/sts.js +124 -124
  129. package/src/providers/codearts.js +1 -1
  130. package/src/providers/dispatcher.js +146 -146
  131. package/src/providers/generic.js +156 -156
  132. package/src/providers/globalqwenwork/account-store.js +141 -141
  133. package/src/providers/globalqwenwork/constants.js +73 -73
  134. package/src/providers/globalqwenwork/crypto.js +2 -2
  135. package/src/providers/globalqwenwork/http.js +2 -2
  136. package/src/providers/globalqwenwork/index.js +331 -331
  137. package/src/providers/globalqwenwork/payload.js +147 -147
  138. package/src/providers/globalqwenwork/rsa.js +2 -2
  139. package/src/providers/globalqwenwork/sse.js +2 -2
  140. package/src/providers/globalqwenwork/stream.js +2 -2
  141. package/src/providers/globalqwenwork.js +1 -1
  142. package/src/providers/globalworkbuddy/account-store.js +198 -198
  143. package/src/providers/globalworkbuddy/auth.js +97 -97
  144. package/src/providers/globalworkbuddy/catalog.js +138 -138
  145. package/src/providers/globalworkbuddy/constants.js +73 -73
  146. package/src/providers/globalworkbuddy/device-auth.js +120 -120
  147. package/src/providers/globalworkbuddy/errors.js +98 -98
  148. package/src/providers/globalworkbuddy/headers.js +89 -89
  149. package/src/providers/globalworkbuddy/models.js +96 -96
  150. package/src/providers/globalworkbuddy/payload.js +80 -80
  151. package/src/providers/index.js +5 -5
  152. package/src/providers/model-id.js +172 -172
  153. package/src/providers/opencode.js +19 -19
  154. package/src/providers/openrouter.js +31 -31
  155. package/src/providers/qoder/account-store.js +106 -106
  156. package/src/providers/qoder/aggregate.js +60 -60
  157. package/src/providers/qoder/baseprompt.json +591 -591
  158. package/src/providers/qoder/checkin.js +160 -160
  159. package/src/providers/qoder/constants.js +44 -44
  160. package/src/providers/qoder/encode.js +41 -41
  161. package/src/providers/qoder/fingerprint.js +38 -38
  162. package/src/providers/qoder/models.js +77 -77
  163. package/src/providers/qoder/oauth.js +84 -84
  164. package/src/providers/qoder/payload.js +162 -162
  165. package/src/providers/qoder/request.js +27 -27
  166. package/src/providers/qoder/session.js +134 -134
  167. package/src/providers/qoder/sticky.js +38 -38
  168. package/src/providers/qoder/stream.js +116 -116
  169. package/src/providers/qoder.js +1 -1
  170. package/src/providers/raccoon/account-store.js +216 -216
  171. package/src/providers/raccoon/auth.js +67 -67
  172. package/src/providers/raccoon/chat.js +228 -228
  173. package/src/providers/raccoon/const.js +180 -180
  174. package/src/providers/raccoon/credits.js +185 -185
  175. package/src/providers/raccoon/crypto.js +43 -43
  176. package/src/providers/raccoon/envelope.js +31 -31
  177. package/src/providers/raccoon/headers.js +45 -45
  178. package/src/providers/raccoon/index.js +216 -216
  179. package/src/providers/raccoon/login.js +279 -279
  180. package/src/providers/raccoon/models.js +160 -160
  181. package/src/providers/raccoon/password-login.js +102 -102
  182. package/src/providers/raccoon/qr-codewords.js +115 -115
  183. package/src/providers/raccoon/qr.js +236 -236
  184. package/src/providers/raccoon/sse.js +135 -135
  185. package/src/providers/responses-channel.js +120 -120
  186. package/src/providers/share-keys.js +53 -53
  187. package/src/providers/traework/account-store.js +234 -234
  188. package/src/providers/traework/chat.js +217 -217
  189. package/src/providers/traework/checkin.js +77 -77
  190. package/src/providers/traework/constants.js +41 -41
  191. package/src/providers/traework/errors.js +52 -52
  192. package/src/providers/traework/headers.js +79 -79
  193. package/src/providers/traework/index.js +132 -132
  194. package/src/providers/traework/market-identity.js +116 -116
  195. package/src/providers/traework/models.js +98 -98
  196. package/src/providers/traework/payload.js +86 -86
  197. package/src/providers/traework/sse.js +184 -184
  198. package/src/providers/traework/token.js +52 -52
  199. package/src/providers/traework.js +2 -2
  200. package/src/providers/workbuddy/account-store.js +186 -186
  201. package/src/providers/workbuddy/auth.js +158 -158
  202. package/src/providers/workbuddy/balance.js +86 -86
  203. package/src/providers/workbuddy/cat-travel.js +125 -125
  204. package/src/providers/workbuddy/checkin.js +75 -75
  205. package/src/providers/workbuddy/desktop-info.js +78 -78
  206. package/src/providers/workbuddy/growth-api.js +67 -67
  207. package/src/providers/workbuddy/growth-plans.js +63 -63
  208. package/src/providers/workbuddy/growth.js +181 -181
  209. package/src/providers/workbuddy/index.js +217 -217
  210. package/src/providers/workbuddy/models.js +126 -126
  211. package/src/providers/workbuddy/payload.js +141 -141
  212. package/src/providers/workbuddy/rotation-log.js +83 -83
  213. package/src/providers/workbuddy/sanitize-tools.js +61 -61
  214. package/src/providers/workbuddy/sdk-chat.js +26 -26
  215. package/src/providers/workbuddy-balance.js +78 -78
  216. package/src/providers/zcode/account-store.js +129 -129
  217. package/src/providers/zcode/auth.js +28 -28
  218. package/src/providers/zcode/chat.js +179 -179
  219. package/src/providers/zcode/const.js +54 -54
  220. package/src/providers/zcode/context-shape.js +162 -162
  221. package/src/providers/zcode/headers.js +93 -93
  222. package/src/providers/zcode/index.js +124 -124
  223. package/src/providers/zcode/models.js +65 -65
  224. package/src/providers/zcode/oauth.js +120 -120
  225. package/src/providers/zcode/quota.js +176 -176
  226. package/src/providers/zcode/sse.js +196 -196
  227. package/src/readline-compat.js +25 -25
  228. package/src/responses/translate.js +311 -311
  229. package/src/routes/AGENTS.md +37 -0
  230. package/src/routes/chat/broadband-handler.js +72 -72
  231. package/src/routes/chat/empty-turn-report.js +31 -31
  232. package/src/routes/chat/exhausted-handler.js +87 -87
  233. package/src/routes/chat/hedge-handler.js +130 -130
  234. package/src/routes/chat/index.js +1 -1
  235. package/src/routes/chat/local-handler.js +63 -63
  236. package/src/routes/chat/peer-handler.js +81 -81
  237. package/src/routes/chat.js +3 -3
  238. package/src/routes/fallback.js +88 -88
  239. package/src/routes/groups-relay.js +214 -214
  240. package/src/routes/groups.js +122 -122
  241. package/src/routes/helpers.js +93 -83
  242. package/src/routes/index.js +147 -147
  243. package/src/routes/messages-route.js +375 -365
  244. package/src/routes/peers.js +10 -2
  245. package/src/routes/relay-queue.js +181 -181
  246. package/src/routes/relay.js +60 -60
  247. package/src/routes/responses-route.js +213 -213
  248. package/src/routes/stream-body.js +84 -84
  249. package/src/routes/stream-hold.js +337 -337
  250. package/src/routes/stream-scan.js +366 -366
  251. package/src/routes.js +3 -3
  252. package/src/runtime/auto-update.js +113 -113
  253. package/src/runtime/broadband-stream.js +79 -79
  254. package/src/runtime/broadband.js +103 -103
  255. package/src/runtime/group-sync.js +81 -81
  256. package/src/runtime/keep-warm.js +120 -120
  257. package/src/runtime/lifecycle-forensics.js +116 -116
  258. package/src/runtime/lifecycle-log.js +87 -87
  259. package/src/runtime/log-stamp.js +73 -73
  260. package/src/runtime/proc-info.js +137 -137
  261. package/src/runtime/qoder-checkin.js +93 -93
  262. package/src/runtime/raccoon-checkin.js +126 -126
  263. package/src/runtime/server-lifecycle.js +189 -189
  264. package/src/runtime/traework-checkin.js +118 -118
  265. package/src/runtime/workbuddy-checkin.js +120 -120
  266. package/src/runtime/workbuddy-growth.js +133 -133
  267. package/src/state/facade.js +58 -58
  268. package/src/state/memory.js +162 -162
  269. package/src/state/merge.js +25 -25
  270. package/src/state/migrations/cline-unify.js +94 -94
  271. package/src/state/migrations.js +33 -33
  272. package/src/state/persist.js +42 -42
  273. package/src/state/provider-config.js +142 -142
  274. package/src/state/schemas/group.js +31 -31
  275. package/src/state/schemas/model.js +94 -94
  276. package/src/state/schemas/peer.js +31 -31
  277. package/src/state/schemas/port.js +10 -10
  278. package/src/state/schemas/provider.js +202 -202
  279. package/src/state/schemas/timezone.js +70 -70
  280. package/src/state/schemas/token.js +48 -48
  281. package/src/state/schemas/use-group.js +102 -102
  282. package/src/state/store.js +176 -176
  283. package/src/state.js +1 -1
  284. package/src/sync-claude-desktop.js +234 -234
  285. package/src/sync-claude.js +152 -152
  286. package/src/sync-codex.js +116 -116
  287. package/src/sync-workbuddy.js +158 -158
  288. package/src/talk-full-redact.js +129 -129
  289. package/src/talk-full.js +131 -131
  290. package/src/talk-log.js +257 -257
  291. package/src/text-tokens.js +52 -52
  292. package/src/thinking-effort.js +67 -67
  293. package/src/time.js +106 -106
  294. package/src/timeline.js +26 -26
  295. package/src/transport/index.js +1 -1
  296. package/src/upstream-engine/index.js +74 -74
  297. package/src/upstream-engine/mode.js +15 -15
  298. package/src/upstream-engine/sdk/attempt.js +221 -221
  299. package/src/upstream-engine/sdk/chat.js +28 -28
  300. package/src/upstream-engine/sdk/convert.js +192 -192
  301. package/src/upstream-engine/sdk/diagnose.js +62 -62
  302. package/src/upstream-engine/sdk/dispatch.js +55 -55
  303. package/src/upstream-engine/sdk/responses.js +176 -176
  304. package/src/upstream-engine/sdk/sse.js +166 -166
  305. package/src/upstream-responses.js +305 -305
  306. package/src/usage/cache-fields.js +58 -0
  307. package/src/usage/record.js +109 -106
  308. package/src/usage/report.js +209 -195
  309. package/src/v6pool/claim.js +157 -157
  310. package/src/v6pool/config.js +65 -65
  311. package/src/v6pool/detect.js +130 -130
  312. package/src/v6pool/index.js +152 -152
  313. package/src/v6pool/prefix.js +140 -140
  314. package/src/v6pool/probe.js +40 -40
  315. package/src/v6pool/report.js +104 -104
  316. package/src/v6pool/rotate.js +263 -263
  317. package/src/warp-pool/config.js +74 -74
  318. package/src/warp-pool/health.js +106 -106
  319. package/src/warp-pool/identity.js +141 -141
  320. package/src/warp-pool/index.js +358 -358
  321. package/src/warp-pool/keys.js +29 -29
  322. package/src/warp-pool/nodes.js +95 -95
  323. package/src/warp-pool/ports.js +160 -160
  324. package/src/warp-pool/register.js +113 -113
  325. package/src/warp-pool/report.js +232 -232
  326. package/src/warp-pool/revive.js +168 -168
  327. package/src/warp-pool/rotate.js +214 -214
  328. package/src/warp-pool/strategy.js +43 -43
  329. package/src/warp-pool/verdict.js +69 -69
  330. package/src/warp-pool/wireproxy.js +162 -162
@@ -1,365 +1,375 @@
1
- /**
2
- * POST /v1/messages + /v1/messages/count_tokens — 给 Claude Code 用的 Anthropic Messages 外壳(ADR-0047)。
3
- * 复用 ChatPipeline 全链路(auto/hedge/failover/tool_calls/空轮重试),只做形状翻译:
4
- * 非流式:收集 chat JSON → 转 Anthropic message(上游只会流式时先把 SSE 聚合成 chat JSON);流式:逐块实时翻成 Anthropic 具名事件。
5
- *
6
- * 出站格式与 /v1/responses **不兼容**,故不能复用 createLiveForwarder(它写死 `data:` + `[DONE]`):
7
- * Anthropic 用 `event: <type>` 具名帧、无 `[DONE]`,且需网关自发 `ping` 扛过客户端 idle watchdog。
8
- *
9
- * 三条收口纪律(第 1 轮评审 P0-2/P0-3 立下的,别改回去):
10
- * 1. 上游一有动静(含 pipeline 的 keepalive 注释帧)就发头 + `message_start` + 武装 ping —— 首块前的静默期正是客户端 watchdog 咬人的窗口;
11
- * 2. `headersSent`/`getHeader` 必须真反映已发头,否则 `helpers.js:32` 的「已 flush 走 in-band 错误帧」守卫永远判假,
12
- * 错误会被当成普通文本喂进 push() 后静默消失;
13
- * 3. 没等到 `finish_reason`(也没有 `[DONE]`)就收线 = 截断,发 `event: error`,**不伪装成功收场**。
14
- */
15
- import { json, readBody } from "./helpers.js";
16
- import { createChatPipeline } from "../chat-pipeline/index.js";
17
- import { withBody, createCollector, createEmitter } from "./responses-route.js";
18
- import {
19
- messagesToChatBody,
20
- chatJsonToAnthropic,
21
- createAnthropicChunkTranslator,
22
- anthropicError,
23
- } from "../anthropic/translate.js";
24
- import { estimateTokens } from "../anthropic/count-tokens.js";
25
- import { sseToChatJson } from "../anthropic/sse-aggregate.js";
26
- import {
27
- detectSearchRequest,
28
- runWebSearch,
29
- searchConfig,
30
- buildSearchMessage,
31
- searchEvents,
32
- } from "../anthropic/web-search.js";
33
-
34
- const ADEBUG = process.env.MSLXDFF_ANTHROPIC_DEBUG === "1";
35
- function alog(...a) {
36
- if (ADEBUG) console.log("[messages]", ...a);
37
- }
38
-
39
- // thinking 块外发:默认开,`MSLXDFF_ANTHROPIC_THINKING=0` 才关。外发的 thinking 块必须在封口前交一帧占位
40
- // signature_delta(translate.js 的 thinkingSignature),缺它 Claude Code 整块丢弃 — ADR-0051 外发 / ADR-0052 签名
41
- function emitThinking() { return process.env.MSLXDFF_ANTHROPIC_THINKING !== "0"; }
42
-
43
- // 空闲 ping 周期(ms):`0` 关闭;客户端长思考期靠它扛过 streaming idle timeout
44
- const PING_MS = (() => {
45
- const raw = Number(process.env.MSLXDFF_ANTHROPIC_PING_MS);
46
- return Number.isFinite(raw) && raw >= 0 ? raw : 15000;
47
- })();
48
-
49
- function frameOf(type, data) {
50
- return `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
51
- }
52
-
53
- // pipeline 在已 flush 的流里用 OpenAI 形状收口(`{error:{...}}` + `[DONE]`):
54
- // 检出后改发 Anthropic 的 `error` 帧,别把错误伪装成正常收场(grill-decisions Q2)
55
- function detectInBandError(text) {
56
- let msg = null;
57
- for (const line of String(text || "").split("\n")) {
58
- const t = line.trim();
59
- if (!t.startsWith("data:")) continue;
60
- const payload = t.slice(5).trim();
61
- if (!payload || payload === "[DONE]") continue;
62
- try {
63
- const j = JSON.parse(payload);
64
- const e = j?.error;
65
- if (e) msg = String(e?.message || e).slice(0, 500);
66
- } catch { /* 非 JSON 帧忽略 */ }
67
- }
68
- return msg;
69
- }
70
-
71
-
72
- // pipeline 未 flush 时用 json() 直写聚合错误体(无 `data:` 前缀):认出来只取 message,
73
- // 别把整坨 OpenAI JSON 当人话塞进 Anthropic error.message。
74
- function jsonErrorText(text) {
75
- const t = String(text || "").trim();
76
- if (!t || t.startsWith(":") || t.includes("data:")) return null;
77
- try {
78
- const j = JSON.parse(t);
79
- const e = j?.error;
80
- if (!e) return null;
81
- return String(e?.message || e).slice(0, 500);
82
- } catch {
83
- return null;
84
- }
85
- }
86
-
87
- /** 两种上游收场形状(in-band `data:` 帧 / 聚合 JSON 体)统一取错误文案。 */
88
- function errorText(text) {
89
- return detectInBandError(text) || jsonErrorText(text);
90
- }
91
- // 客户端要流式而上游给了聚合 JSON(muse-spark / workbuddy 聚合 / zcode 非流式聚合都是现网常态):
92
- // 那体没有 `data:` 前缀,会被 push() 当噪声整段丢掉 → 正文静默消失。认出来交给 fromChatJson 兜底。
93
- function tryAggregate(text) {
94
- const t = String(text || "").trim();
95
- if (!t || t.includes("data:") || t.startsWith(":")) return null;
96
- try {
97
- const j = JSON.parse(t);
98
- // 只认「非空 choices」:`{choices:[]}` 之类的空包什么也带不来,若当合法聚合包会跳过截断检测 → 零正文还谎报成功
99
- return j && Array.isArray(j.choices) && j.choices.length > 0 ? j : null;
100
- } catch {
101
- return null;
102
- }
103
- }
104
-
105
-
106
- /**
107
- * Anthropic 出站垫片:具名事件 + 无 [DONE] + 空闲自发 ping + 真 headersSent。
108
- * @param realRes 真 HTTP response
109
- * @param translator createAnthropicChunkTranslator 实例
110
- * @param opts { pingMs?: number } 测试注入口,缺省读 env
111
- */
112
- export function createAnthropicForwarder(realRes, translator, opts = {}) {
113
- const pingMs = opts.pingMs ?? PING_MS;
114
- let status = 200;
115
- let headSent = false;
116
- let ended = false;
117
- let timer = null;
118
- let pendingError = null;
119
- let aggregate = null;
120
- let rawAcc = ""; // 攒住无事件字节,供聚合回落识别
121
- const sent = () => headSent || realRes.headersSent === true; // headersSent 与 getHeader 的唯一口径
122
- // 上游按字节粒度喂 Buffer/Uint8Array:非流式解码会把多字节汉字切成 U+FFFD 并永久固化在行缓冲里
123
- const decoder = new TextDecoder("utf-8");
124
- const decode = (c, flush) => {
125
- if (c == null) return flush ? String(decoder.decode()) : "";
126
- if (typeof c === "string") return c;
127
- const bytes = (typeof Buffer !== "undefined" && Buffer.isBuffer(c)) || c instanceof Uint8Array ? c : null;
128
- if (bytes) return decoder.decode(bytes, { stream: !flush });
129
- return String(c);
130
- };
131
- const dead = () => ended || realRes.writableEnded === true || realRes.destroyed === true;
132
- const raw = (type, data) => { if (dead()) return; try { realRes.write(frameOf(type, data)); } catch { /* 下游已断 */ } };
133
- const stopPing = () => { if (timer) { clearTimeout(timer); timer = null; } };
134
- const armPing = () => {
135
- if (!pingMs || dead()) return;
136
- stopPing();
137
- timer = setTimeout(() => { if (dead()) return; raw("ping", { type: "ping" }); armPing(); }, pingMs);
138
- if (typeof timer.unref === "function") timer.unref();
139
- };
140
- const sendHead = () => {
141
- if (headSent) return;
142
- headSent = true;
143
- try { realRes.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }); } catch { /* 已发 */ }
144
- for (const e of translator.begin()) raw(e.type, e);
145
- armPing();
146
- };
147
- const finish = () => { if (ended) return; ended = true; stopPing(); try { realRes.end(); } catch { /* ignore */ } };
148
- // 错误收场:一帧 error 后关连接,不补 message_delta/message_stop(真 Anthropic API 即此行为)
149
- const closeError = (msg) => { sendHead(); raw("error", { type: "error", error: { type: "api_error", message: String(msg || "upstream error").slice(0, 500) } }); finish(); };
150
-
151
- // 整包聚合体可能被分多次 write:攒住「没产出任何事件」的字节再判一次(有事件即清空,防陈旧拼接)
152
- const RAW_ACC_CAP = 262144;
153
- let rawOverflow = false;
154
- const tryAccumulate = (text) => {
155
- if (aggregate || !text || rawOverflow) return;
156
- rawAcc += text;
157
- if (rawAcc.length > RAW_ACC_CAP) {
158
- // 超上限就停手并留标记:继续攒也拼不出完整 JSON,静默攒下去只会让错误文案说谎
159
- rawAcc = rawAcc.slice(0, RAW_ACC_CAP);
160
- rawOverflow = true;
161
- }
162
- const agg = tryAggregate(rawAcc);
163
- if (agg) aggregate = agg;
164
- };
165
- const res = {
166
- ...createEmitter(),
167
- set statusCode(v) { status = v; },
168
- get statusCode() { return status; },
169
- // helpers.js 的 json() 靠 headersSent + getHeader("content-type") 判「能不能回头」;
170
- // 两者必须同源说真话,否则会出现「判成已 flush、却又拿不到 event-stream」的全静默窗口(错误既不 in-band 也不 JSON)。
171
- get headersSent() { return sent(); },
172
- // json() 的幂等早退判据;补上它才不会在我们已收场后再被当「可写」的响应对待
173
- get writableEnded() { return ended || realRes.writableEnded === true; },
174
- setHeader() { /* Anthropic SSE 的头由 sendHead 统一发,逐键 set 无意义 */ },
175
- getHeader(name) {
176
- if (String(name).toLowerCase() === "content-type" && sent()) return "text/event-stream";
177
- return undefined;
178
- },
179
- write(c) {
180
- if (ended) { alog("late-write-ignored", JSON.stringify({ id: translator.id, bytes: String(c?.length ?? 0) })); return true; }
181
- const text = decode(c);
182
- const err = errorText(text);
183
- if (err) { pendingError = err; return true; } // 留给 end() 统一收口,不在这里半截发
184
- // 上游一有动静(连 pipeline 的 keepalive 注释帧都算)就落头 + message_start + 武装 ping:
185
- // 首块前的静默期正是客户端 idle watchdog 咬人的窗口。没收到任何字节时错误仍走干净 JSON(见 end())。
186
- sendHead();
187
- const evs = translator.push(text);
188
- for (const e of evs) raw(e.type, e);
189
- if (evs.length) rawAcc = ""; else tryAccumulate(text);
190
- armPing();
191
- return true;
192
- },
193
- end(c) {
194
- if (ended) return;
195
- const tail = decode(c, true);
196
- const err = pendingError || errorText(tail);
197
- if (status >= 400 && !headSent) {
198
- ended = true;
199
- stopPing();
200
- json(realRes, status, anthropicError("api_error", err || String(tail).slice(0, 500) || "upstream error"));
201
- return;
202
- }
203
- if (err) { closeError(err); return; }
204
- // 客户端要的是流:先落头 + message_start + 武装 ping,再处理残余
205
- sendHead();
206
- if (tail) {
207
- const evs = translator.push(tail);
208
- for (const e of evs) raw(e.type, e);
209
- if (evs.length) rawAcc = ""; else tryAccumulate(tail);
210
- }
211
- // 末帧常常没有收尾换行:补一个 `\n` 把残行逼出来,否则合法正文与 finish_reason 会一起烂在缓冲里(被误判成截断)
212
- for (const e of translator.push("\n")) raw(e.type, e);
213
- // 上游整包聚合(无 SSE 帧):喂同一套状态机兜底,别让正文静默消失
214
- if (aggregate && !translator.stats().blocks) { for (const e of translator.fromChatJson(aggregate)) raw(e.type, e); }
215
- const fin = translator.getFinal();
216
- if (!fin.finish && !aggregate) {
217
- alog("truncated", JSON.stringify({ id: translator.id, ...translator.stats() }));
218
- closeError(rawOverflow
219
- ? "upstream sent an oversized non-SSE body that could not be aggregated (no finish_reason)"
220
- : "upstream stream ended before the model finished its turn (no finish_reason)");
221
- return;
222
- }
223
- for (const e of translator.end({ finish: fin.finish, usage: fin.usage })) raw(e.type, e);
224
- finish();
225
- },
226
- };
227
- // 客户端提前断开:pipeline 会感知 close,但 ping 定时器归本垫片管,必须自己停
228
- try { realRes.on?.("close", () => { if (!ended) { ended = true; stopPing(); } }); } catch { /* ignore */ }
229
- return res;
230
- }
231
-
232
- /**
233
- * 代跑结果回给客户端:非流式整包 JSON,流式由同一份块生成的具名事件(无 `[DONE]`)。
234
- * 搜索在调用前就跑完了,这里一次写完即可,不需要 createAnthropicForwarder 的 ping 垫片。
235
- */
236
- function respondSearch(res, { message, stream, provider }, isDead) {
237
- if (isDead && isDead()) return undefined; // 搜索期间客户端断连:别再写任何字节
238
- if (!stream) {
239
- try { res.setHeader?.("x-mslxdff-web-search", provider); } catch { /* 已 flush */ }
240
- try { return json(res, 200, message); } catch { return undefined; /* 下游已断 */ }
241
- }
242
- try {
243
- res.writeHead(200, {
244
- "Content-Type": "text/event-stream",
245
- "Cache-Control": "no-cache",
246
- Connection: "keep-alive",
247
- "x-mslxdff-web-search": provider,
248
- });
249
- for (const e of searchEvents(message)) res.write(frameOf(e.type, e));
250
- res.end();
251
- } catch { /* 下游已断 */ }
252
- return undefined;
253
- }
254
-
255
- export async function messagesHandler(ctx) {
256
- const { req, res } = ctx;
257
- const t0 = Date.now();
258
- let body;
259
- try {
260
- body = await readBody(req);
261
- } catch {
262
- return json(res, 400, anthropicError("invalid_request_error", "Invalid JSON body"));
263
- }
264
- // —— 网关代跑 web_search(ADR-0049):旁路请求**一律由网关作答**——命中判据即不打模型。
265
- // 搜到就回结果块;关了/全挂/空查询就回一条明确的 400(把无工具的旁路丢给上游,只会让模型凭空
266
- // 编 URL 并让客户端按「0 次搜索」空转重试,那才是改前的老毛病)。主循环请求不受影响。
267
- // 整段包 try:搜索期最长 15s,其间客户端可能已断连(`isDead`),任何意外都不得逃逸成 5xx。
268
- const det = detectSearchRequest(body);
269
- if (det.hit) {
270
- const isDead = () => res.writableEnded === true || res.destroyed === true || res.headersSent === true;
271
- const wsCfg = searchConfig();
272
- let r;
273
- if (!wsCfg.enabled) r = { ok: false, error: "disabled by MSLXDFF_WEB_SEARCH=off" };
274
- else {
275
- try {
276
- r = det.query ? await runWebSearch(det.query, wsCfg) : { ok: false, error: "empty query" };
277
- } catch (e) {
278
- r = { ok: false, error: String(e?.message || e) };
279
- }
280
- }
281
- if (r.ok) {
282
- const readout = { provider: r.provider, ms: r.ms, results: r.results.length, queryChars: r.query.length, chain: r.chain || [] };
283
- alog("web-search", JSON.stringify(readout));
284
- try { ctx.logs?.appendEvent?.({ type: "web-search", ...readout }); } catch { /* 观测产物不参与判决 */ }
285
- return respondSearch(res, {
286
- message: buildSearchMessage({ model: String(body.model || ""), query: r.query, results: r.results }),
287
- stream: Boolean(body.stream),
288
- provider: r.provider,
289
- }, isDead);
290
- }
291
- const why = String(r.error || "unknown").slice(0, 200);
292
- alog("web-search-unavailable", why);
293
- try { ctx.logs?.appendEvent?.({ type: "web-search-unavailable", queryChars: (det.query || "").length, error: why, chain: r.chain || [] }); } catch { /* 同上 */ }
294
- if (isDead()) return undefined;
295
- try { return json(res, 400, anthropicError("invalid_request_error", `web search failed at the gateway: ${why}`)); } catch { return undefined; }
296
- }
297
- let chatBody;
298
- try {
299
- chatBody = messagesToChatBody(body);
300
- } catch (e) {
301
- alog("translate-req-400", String(e?.message || e));
302
- return json(res, 400, anthropicError("invalid_request_error", String(e?.message || e)));
303
- }
304
- alog("req", JSON.stringify({
305
- model: chatBody.model, stream: chatBody.stream,
306
- msgs: chatBody.messages.map((m) => `${m.role}:${(typeof m.content === "string" ? m.content : "[parts]").length}${m.tool_calls ? `+${m.tool_calls.length}tc` : ""}`),
307
- tools: Array.isArray(chatBody.tools) ? chatBody.tools.length : 0,
308
- }));
309
- const pipeline = createChatPipeline(ctx);
310
- const fakeReq = withBody(req, chatBody);
311
- // ADR-0055:请求侧 token 估算 thunk(路由层也 memoize,与决策 4「每请求至多算一次」对齐;
312
- // translator 内部还有一层 memoize,双保险)。
313
- let estCache;
314
- const estInput = () => (estCache ??= estimateTokens(body).input_tokens);
315
- const forwardClose = (shim) => { try { req.on?.("close", () => shim.emit("close")); } catch { /* ignore */ } };
316
- try {
317
- if (chatBody.stream) {
318
- const translator = createAnthropicChunkTranslator(chatBody.model, { thinking: emitThinking(), estInput });
319
- const live = createAnthropicForwarder(res, translator);
320
- forwardClose(live);
321
- await pipeline.execute({ req: fakeReq, res: live });
322
- alog("done-stream", JSON.stringify({ ms: Date.now() - t0, id: translator.id, ...translator.stats(), ...translator.getFinal() }));
323
- } else {
324
- const cap = createCollector();
325
- forwardClose(cap.res);
326
- await pipeline.execute({ req: fakeReq, res: cap.res });
327
- const { status, text } = cap.get();
328
- if (status >= 400) return json(res, status, anthropicError("api_error", errorText(text) || String(text).slice(0, 500)));
329
- let chatJson = null;
330
- try { chatJson = JSON.parse(text); } catch { /* 非 JSON */ }
331
- // 上游只会流式(workbuddy 写死 stream:true):非流式收到的 text 是 SSE → 聚成 chat JSON 再走同一翻译出口;
332
- // 空流/错误帧聚合不出 JSON 时仍落回下方原 502 路径。
333
- if (!chatJson) chatJson = sseToChatJson(text, chatBody.model);
334
- if (!chatJson || chatJson.error || chatJson.object === "error") {
335
- return json(res, status >= 400 ? status : 502, anthropicError("api_error", errorText(text) || String(text).slice(0, 500)));
336
- }
337
- // 上游偶发非 chat 形状(无 choices):包成单 text 块,绝不 500(照 responses-route.js 先例)
338
- if (!Array.isArray(chatJson.choices)) {
339
- const r = chatJsonToAnthropic({ choices: [{ message: { content: String(text).slice(0, 8000) } }] }, chatBody.model, { estInput });
340
- alog("done-nonchat", JSON.stringify({ ms: Date.now() - t0, bytes: text.length }));
341
- return json(res, 200, r);
342
- }
343
- const out = chatJsonToAnthropic(chatJson, chatBody.model, { estInput });
344
- alog("done-json", JSON.stringify({ ms: Date.now() - t0, status, blocks: out.content.length, stop: out.stop_reason, usage: out.usage }));
345
- return json(res, 200, out);
346
- }
347
- } catch (err) {
348
- alog("execute-throw", String(err?.message || err).slice(0, 300));
349
- if (!res.headersSent) return json(res, 502, anthropicError("api_error", String(err?.message || err)));
350
- try { res.end(); } catch { /* ignore */ }
351
- }
352
- }
353
-
354
- export async function countTokensHandler(ctx) {
355
- const { req, res } = ctx;
356
- let body;
357
- try {
358
- body = await readBody(req);
359
- } catch {
360
- return json(res, 400, anthropicError("invalid_request_error", "Invalid JSON body"));
361
- }
362
- const out = estimateTokens(body);
363
- alog("count-tokens", JSON.stringify({ model: body?.model, input_tokens: out.input_tokens }));
364
- return json(res, 200, out);
365
- }
1
+ /**
2
+ * POST /v1/messages + /v1/messages/count_tokens — 给 Claude Code 用的 Anthropic Messages 外壳(ADR-0047)。
3
+ * 复用 ChatPipeline 全链路(auto/hedge/failover/tool_calls/空轮重试),只做形状翻译:
4
+ * 非流式:收集 chat JSON → 转 Anthropic message(上游只会流式时先把 SSE 聚合成 chat JSON);流式:逐块实时翻成 Anthropic 具名事件。
5
+ *
6
+ * 出站格式与 /v1/responses **不兼容**,故不能复用 createLiveForwarder(它写死 `data:` + `[DONE]`):
7
+ * Anthropic 用 `event: <type>` 具名帧、无 `[DONE]`,且需网关自发 `ping` 扛过客户端 idle watchdog。
8
+ *
9
+ * 三条收口纪律(第 1 轮评审 P0-2/P0-3 立下的,别改回去):
10
+ * 1. 上游一有动静(含 pipeline 的 keepalive 注释帧)就发头 + `message_start` + 武装 ping —— 首块前的静默期正是客户端 watchdog 咬人的窗口;
11
+ * 2. `headersSent`/`getHeader` 必须真反映已发头,否则 `helpers.js:32` 的「已 flush 走 in-band 错误帧」守卫永远判假,
12
+ * 错误会被当成普通文本喂进 push() 后静默消失;
13
+ * 3. 没等到 `finish_reason`(也没有 `[DONE]`)就收线 = 截断,发 `event: error`,**不伪装成功收场**。
14
+ */
15
+ import { json, readBody } from "./helpers.js";
16
+ import { createChatPipeline } from "../chat-pipeline/index.js";
17
+ import { withBody, createCollector, createEmitter } from "./responses-route.js";
18
+ import {
19
+ messagesToChatBody,
20
+ chatJsonToAnthropic,
21
+ createAnthropicChunkTranslator,
22
+ anthropicError,
23
+ } from "../anthropic/translate.js";
24
+ import { estimateTokens } from "../anthropic/count-tokens.js";
25
+ import { sseToChatJson } from "../anthropic/sse-aggregate.js";
26
+ import { claudeSessionId } from "../anthropic/session-id.js";
27
+ import {
28
+ detectSearchRequest,
29
+ runWebSearch,
30
+ searchConfig,
31
+ buildSearchMessage,
32
+ searchEvents,
33
+ } from "../anthropic/web-search.js";
34
+
35
+ const ADEBUG = process.env.MSLXDFF_ANTHROPIC_DEBUG === "1";
36
+ function alog(...a) {
37
+ if (ADEBUG) console.log("[messages]", ...a);
38
+ }
39
+
40
+ // thinking 块外发:默认开,`MSLXDFF_ANTHROPIC_THINKING=0` 才关。外发的 thinking 块必须在封口前交一帧占位
41
+ // signature_delta(translate.js 的 thinkingSignature),缺它 Claude Code 整块丢弃 — ADR-0051 外发 / ADR-0052 签名
42
+ function emitThinking() { return process.env.MSLXDFF_ANTHROPIC_THINKING !== "0"; }
43
+
44
+ // 空闲 ping 周期(ms):`0` 关闭;客户端长思考期靠它扛过 streaming idle timeout
45
+ const PING_MS = (() => {
46
+ const raw = Number(process.env.MSLXDFF_ANTHROPIC_PING_MS);
47
+ return Number.isFinite(raw) && raw >= 0 ? raw : 15000;
48
+ })();
49
+
50
+ function frameOf(type, data) {
51
+ return `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
52
+ }
53
+
54
+ // pipeline 在已 flush 的流里用 OpenAI 形状收口(`{error:{...}}` + `[DONE]`):
55
+ // 检出后改发 Anthropic 的 `error` 帧,别把错误伪装成正常收场(grill-decisions Q2)
56
+ function detectInBandError(text) {
57
+ let msg = null;
58
+ for (const line of String(text || "").split("\n")) {
59
+ const t = line.trim();
60
+ if (!t.startsWith("data:")) continue;
61
+ const payload = t.slice(5).trim();
62
+ if (!payload || payload === "[DONE]") continue;
63
+ try {
64
+ const j = JSON.parse(payload);
65
+ const e = j?.error;
66
+ if (e) msg = String(e?.message || e).slice(0, 500);
67
+ } catch { /* 非 JSON 帧忽略 */ }
68
+ }
69
+ return msg;
70
+ }
71
+
72
+
73
+ // pipeline 未 flush 时用 json() 直写聚合错误体(无 `data:` 前缀):认出来只取 message,
74
+ // 别把整坨 OpenAI JSON 当人话塞进 Anthropic error.message。
75
+ function jsonErrorText(text) {
76
+ const t = String(text || "").trim();
77
+ if (!t || t.startsWith(":") || t.includes("data:")) return null;
78
+ try {
79
+ const j = JSON.parse(t);
80
+ const e = j?.error;
81
+ if (!e) return null;
82
+ return String(e?.message || e).slice(0, 500);
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /** 两种上游收场形状(in-band `data:` 帧 / 聚合 JSON 体)统一取错误文案。 */
89
+ function errorText(text) {
90
+ return detectInBandError(text) || jsonErrorText(text);
91
+ }
92
+ // 客户端要流式而上游给了聚合 JSON(muse-spark / workbuddy 聚合 / zcode 非流式聚合都是现网常态):
93
+ // 那体没有 `data:` 前缀,会被 push() 当噪声整段丢掉 → 正文静默消失。认出来交给 fromChatJson 兜底。
94
+ function tryAggregate(text) {
95
+ const t = String(text || "").trim();
96
+ if (!t || t.includes("data:") || t.startsWith(":")) return null;
97
+ try {
98
+ const j = JSON.parse(t);
99
+ // 只认「非空 choices」:`{choices:[]}` 之类的空包什么也带不来,若当合法聚合包会跳过截断检测 → 零正文还谎报成功
100
+ return j && Array.isArray(j.choices) && j.choices.length > 0 ? j : null;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+
107
+ /**
108
+ * Anthropic 出站垫片:具名事件 + 无 [DONE] + 空闲自发 ping + 真 headersSent。
109
+ * @param realRes 真 HTTP response
110
+ * @param translator createAnthropicChunkTranslator 实例
111
+ * @param opts { pingMs?: number } 测试注入口,缺省读 env
112
+ */
113
+ export function createAnthropicForwarder(realRes, translator, opts = {}) {
114
+ const pingMs = opts.pingMs ?? PING_MS;
115
+ let status = 200;
116
+ let headSent = false;
117
+ let ended = false;
118
+ let timer = null;
119
+ let pendingError = null;
120
+ let aggregate = null;
121
+ let rawAcc = ""; // 攒住无事件字节,供聚合回落识别
122
+ const sent = () => headSent || realRes.headersSent === true; // headersSent 与 getHeader 的唯一口径
123
+ // 上游按字节粒度喂 Buffer/Uint8Array:非流式解码会把多字节汉字切成 U+FFFD 并永久固化在行缓冲里
124
+ const decoder = new TextDecoder("utf-8");
125
+ const decode = (c, flush) => {
126
+ if (c == null) return flush ? String(decoder.decode()) : "";
127
+ if (typeof c === "string") return c;
128
+ const bytes = (typeof Buffer !== "undefined" && Buffer.isBuffer(c)) || c instanceof Uint8Array ? c : null;
129
+ if (bytes) return decoder.decode(bytes, { stream: !flush });
130
+ return String(c);
131
+ };
132
+ const dead = () => ended || realRes.writableEnded === true || realRes.destroyed === true;
133
+ const raw = (type, data) => { if (dead()) return; try { realRes.write(frameOf(type, data)); } catch { /* 下游已断 */ } };
134
+ const stopPing = () => { if (timer) { clearTimeout(timer); timer = null; } };
135
+ const armPing = () => {
136
+ if (!pingMs || dead()) return;
137
+ stopPing();
138
+ timer = setTimeout(() => { if (dead()) return; raw("ping", { type: "ping" }); armPing(); }, pingMs);
139
+ if (typeof timer.unref === "function") timer.unref();
140
+ };
141
+ const sendHead = () => {
142
+ if (headSent) return;
143
+ headSent = true;
144
+ try { realRes.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }); } catch { /* 已发 */ }
145
+ for (const e of translator.begin()) raw(e.type, e);
146
+ armPing();
147
+ };
148
+ const finish = () => { if (ended) return; ended = true; stopPing(); try { realRes.end(); } catch { /* ignore */ } };
149
+ // 错误收场:一帧 error 后关连接,不补 message_delta/message_stop(真 Anthropic API 即此行为)
150
+ const closeError = (msg) => { sendHead(); raw("error", { type: "error", error: { type: "api_error", message: String(msg || "upstream error").slice(0, 500) } }); finish(); };
151
+
152
+ // 整包聚合体可能被分多次 write:攒住「没产出任何事件」的字节再判一次(有事件即清空,防陈旧拼接)
153
+ const RAW_ACC_CAP = 262144;
154
+ let rawOverflow = false;
155
+ const tryAccumulate = (text) => {
156
+ if (aggregate || !text || rawOverflow) return;
157
+ rawAcc += text;
158
+ if (rawAcc.length > RAW_ACC_CAP) {
159
+ // 超上限就停手并留标记:继续攒也拼不出完整 JSON,静默攒下去只会让错误文案说谎
160
+ rawAcc = rawAcc.slice(0, RAW_ACC_CAP);
161
+ rawOverflow = true;
162
+ }
163
+ const agg = tryAggregate(rawAcc);
164
+ if (agg) aggregate = agg;
165
+ };
166
+ const res = {
167
+ ...createEmitter(),
168
+ set statusCode(v) { status = v; },
169
+ get statusCode() { return status; },
170
+ // helpers.js 的 json() 靠 headersSent + getHeader("content-type") 判「能不能回头」;
171
+ // 两者必须同源说真话,否则会出现「判成已 flush、却又拿不到 event-stream」的全静默窗口(错误既不 in-band 也不 JSON)。
172
+ get headersSent() { return sent(); },
173
+ // json() 的幂等早退判据;补上它才不会在我们已收场后再被当「可写」的响应对待
174
+ get writableEnded() { return ended || realRes.writableEnded === true; },
175
+ setHeader() { /* Anthropic SSE 的头由 sendHead 统一发,逐键 set 无意义 */ },
176
+ getHeader(name) {
177
+ if (String(name).toLowerCase() === "content-type" && sent()) return "text/event-stream";
178
+ return undefined;
179
+ },
180
+ write(c) {
181
+ if (ended) { alog("late-write-ignored", JSON.stringify({ id: translator.id, bytes: String(c?.length ?? 0) })); return true; }
182
+ const text = decode(c);
183
+ const err = errorText(text);
184
+ if (err) { pendingError = err; return true; } // 留给 end() 统一收口,不在这里半截发
185
+ // 上游一有动静(连 pipeline 的 keepalive 注释帧都算)就落头 + message_start + 武装 ping:
186
+ // 首块前的静默期正是客户端 idle watchdog 咬人的窗口。没收到任何字节时错误仍走干净 JSON(见 end())。
187
+ sendHead();
188
+ const evs = translator.push(text);
189
+ for (const e of evs) raw(e.type, e);
190
+ if (evs.length) rawAcc = ""; else tryAccumulate(text);
191
+ armPing();
192
+ return true;
193
+ },
194
+ end(c) {
195
+ if (ended) return;
196
+ const tail = decode(c, true);
197
+ const err = pendingError || errorText(tail);
198
+ if (status >= 400 && !headSent) {
199
+ ended = true;
200
+ stopPing();
201
+ json(realRes, status, anthropicError("api_error", err || String(tail).slice(0, 500) || "upstream error"));
202
+ return;
203
+ }
204
+ if (err) { closeError(err); return; }
205
+ // 客户端要的是流:先落头 + message_start + 武装 ping,再处理残余
206
+ sendHead();
207
+ if (tail) {
208
+ const evs = translator.push(tail);
209
+ for (const e of evs) raw(e.type, e);
210
+ if (evs.length) rawAcc = ""; else tryAccumulate(tail);
211
+ }
212
+ // 末帧常常没有收尾换行:补一个 `\n` 把残行逼出来,否则合法正文与 finish_reason 会一起烂在缓冲里(被误判成截断)
213
+ for (const e of translator.push("\n")) raw(e.type, e);
214
+ // 上游整包聚合(无 SSE 帧):喂同一套状态机兜底,别让正文静默消失
215
+ if (aggregate && !translator.stats().blocks) { for (const e of translator.fromChatJson(aggregate)) raw(e.type, e); }
216
+ const fin = translator.getFinal();
217
+ if (!fin.finish && !aggregate) {
218
+ alog("truncated", JSON.stringify({ id: translator.id, ...translator.stats() }));
219
+ closeError(rawOverflow
220
+ ? "upstream sent an oversized non-SSE body that could not be aggregated (no finish_reason)"
221
+ : "upstream stream ended before the model finished its turn (no finish_reason)");
222
+ return;
223
+ }
224
+ for (const e of translator.end({ finish: fin.finish, usage: fin.usage })) raw(e.type, e);
225
+ finish();
226
+ },
227
+ };
228
+ // 客户端提前断开:pipeline 会感知 close,但 ping 定时器归本垫片管,必须自己停
229
+ try { realRes.on?.("close", () => { if (!ended) { ended = true; stopPing(); } }); } catch { /* ignore */ }
230
+ return res;
231
+ }
232
+
233
+ /**
234
+ * 代跑结果回给客户端:非流式整包 JSON,流式由同一份块生成的具名事件(无 `[DONE]`)。
235
+ * 搜索在调用前就跑完了,这里一次写完即可,不需要 createAnthropicForwarder 的 ping 垫片。
236
+ */
237
+ function respondSearch(res, { message, stream, provider }, isDead) {
238
+ if (isDead && isDead()) return undefined; // 搜索期间客户端断连:别再写任何字节
239
+ if (!stream) {
240
+ try { res.setHeader?.("x-mslxdff-web-search", provider); } catch { /* 已 flush */ }
241
+ try { return json(res, 200, message); } catch { return undefined; /* 下游已断 */ }
242
+ }
243
+ try {
244
+ res.writeHead(200, {
245
+ "Content-Type": "text/event-stream",
246
+ "Cache-Control": "no-cache",
247
+ Connection: "keep-alive",
248
+ "x-mslxdff-web-search": provider,
249
+ });
250
+ for (const e of searchEvents(message)) res.write(frameOf(e.type, e));
251
+ res.end();
252
+ } catch { /* 下游已断 */ }
253
+ return undefined;
254
+ }
255
+
256
+ export async function messagesHandler(ctx) {
257
+ const { req, res } = ctx;
258
+ const t0 = Date.now();
259
+ let body;
260
+ try {
261
+ body = await readBody(req);
262
+ } catch {
263
+ return json(res, 400, anthropicError("invalid_request_error", "Invalid JSON body"));
264
+ }
265
+ // —— 网关代跑 web_search(ADR-0049):旁路请求**一律由网关作答**——命中判据即不打模型。
266
+ // 搜到就回结果块;关了/全挂/空查询就回一条明确的 400(把无工具的旁路丢给上游,只会让模型凭空
267
+ // 编 URL 并让客户端按「0 次搜索」空转重试,那才是改前的老毛病)。主循环请求不受影响。
268
+ // 整段包 try:搜索期最长 15s,其间客户端可能已断连(`isDead`),任何意外都不得逃逸成 5xx。
269
+ const det = detectSearchRequest(body);
270
+ if (det.hit) {
271
+ const isDead = () => res.writableEnded === true || res.destroyed === true || res.headersSent === true;
272
+ const wsCfg = searchConfig();
273
+ let r;
274
+ if (!wsCfg.enabled) r = { ok: false, error: "disabled by MSLXDFF_WEB_SEARCH=off" };
275
+ else {
276
+ try {
277
+ r = det.query ? await runWebSearch(det.query, wsCfg) : { ok: false, error: "empty query" };
278
+ } catch (e) {
279
+ r = { ok: false, error: String(e?.message || e) };
280
+ }
281
+ }
282
+ if (r.ok) {
283
+ const readout = { provider: r.provider, ms: r.ms, results: r.results.length, queryChars: r.query.length, chain: r.chain || [] };
284
+ alog("web-search", JSON.stringify(readout));
285
+ try { ctx.logs?.appendEvent?.({ type: "web-search", ...readout }); } catch { /* 观测产物不参与判决 */ }
286
+ return respondSearch(res, {
287
+ message: buildSearchMessage({ model: String(body.model || ""), query: r.query, results: r.results }),
288
+ stream: Boolean(body.stream),
289
+ provider: r.provider,
290
+ }, isDead);
291
+ }
292
+ const why = String(r.error || "unknown").slice(0, 200);
293
+ alog("web-search-unavailable", why);
294
+ try { ctx.logs?.appendEvent?.({ type: "web-search-unavailable", queryChars: (det.query || "").length, error: why, chain: r.chain || [] }); } catch { /* 同上 */ }
295
+ if (isDead()) return undefined;
296
+ try { return json(res, 400, anthropicError("invalid_request_error", `web search failed at the gateway: ${why}`)); } catch { return undefined; }
297
+ }
298
+ let chatBody;
299
+ try {
300
+ chatBody = messagesToChatBody(body);
301
+ } catch (e) {
302
+ alog("translate-req-400", String(e?.message || e));
303
+ return json(res, 400, anthropicError("invalid_request_error", String(e?.message || e)));
304
+ }
305
+ alog("req", JSON.stringify({
306
+ model: chatBody.model, stream: chatBody.stream,
307
+ msgs: chatBody.messages.map((m) => `${m.role}:${(typeof m.content === "string" ? m.content : "[parts]").length}${m.tool_calls ? `+${m.tool_calls.length}tc` : ""}`),
308
+ tools: Array.isArray(chatBody.tools) ? chatBody.tools.length : 0,
309
+ }));
310
+ const pipeline = createChatPipeline(ctx);
311
+ const fakeReq = withBody(req, chatBody);
312
+ // 会话身份(照抄 cc-switch session.rs:107-231):Claude Code 把会话 id 放在**请求体 metadata**里
313
+ // (metadata.user_id 的 JSON session_id / 旧 `_session_` 后缀 / metadata.session_id),只读 header 会漏,
314
+ // 上游的缓存亲和与粘性路由就拿不到稳定 key ⇒ 每轮换 key。这里补进既有的 x-session-id 通道
315
+ // (chat-pipeline 已消费该头;不改管线,只影响 /v1/messages)。
316
+ const sid = claudeSessionId(req.headers, body);
317
+ if (sid) {
318
+ fakeReq.headers = { ...fakeReq.headers, "x-session-id": fakeReq.headers?.["x-session-id"] || sid.sessionId };
319
+ alog("session", JSON.stringify({ source: sid.source, id: sid.sessionId.slice(0, 12) }));
320
+ }
321
+ // ADR-0055:请求侧 token 估算 thunk(路由层也 memoize,与决策 4「每请求至多算一次」对齐;
322
+ // translator 内部还有一层 memoize,双保险)。
323
+ let estCache;
324
+ const estInput = () => (estCache ??= estimateTokens(body).input_tokens);
325
+ const forwardClose = (shim) => { try { req.on?.("close", () => shim.emit("close")); } catch { /* ignore */ } };
326
+ try {
327
+ if (chatBody.stream) {
328
+ const translator = createAnthropicChunkTranslator(chatBody.model, { thinking: emitThinking(), estInput });
329
+ const live = createAnthropicForwarder(res, translator);
330
+ forwardClose(live);
331
+ await pipeline.execute({ req: fakeReq, res: live });
332
+ alog("done-stream", JSON.stringify({ ms: Date.now() - t0, id: translator.id, ...translator.stats(), ...translator.getFinal() }));
333
+ } else {
334
+ const cap = createCollector();
335
+ forwardClose(cap.res);
336
+ await pipeline.execute({ req: fakeReq, res: cap.res });
337
+ const { status, text } = cap.get();
338
+ if (status >= 400) return json(res, status, anthropicError("api_error", errorText(text) || String(text).slice(0, 500)));
339
+ let chatJson = null;
340
+ try { chatJson = JSON.parse(text); } catch { /* 非 JSON */ }
341
+ // 上游只会流式(workbuddy 写死 stream:true):非流式收到的 text 是 SSE → 聚成 chat JSON 再走同一翻译出口;
342
+ // 空流/错误帧聚合不出 JSON 时仍落回下方原 502 路径。
343
+ if (!chatJson) chatJson = sseToChatJson(text, chatBody.model);
344
+ if (!chatJson || chatJson.error || chatJson.object === "error") {
345
+ return json(res, status >= 400 ? status : 502, anthropicError("api_error", errorText(text) || String(text).slice(0, 500)));
346
+ }
347
+ // 上游偶发非 chat 形状(无 choices):包成单 text 块,绝不 500(照 responses-route.js 先例)
348
+ if (!Array.isArray(chatJson.choices)) {
349
+ const r = chatJsonToAnthropic({ choices: [{ message: { content: String(text).slice(0, 8000) } }] }, chatBody.model, { estInput, thinking: emitThinking() });
350
+ alog("done-nonchat", JSON.stringify({ ms: Date.now() - t0, bytes: text.length }));
351
+ return json(res, 200, r);
352
+ }
353
+ const out = chatJsonToAnthropic(chatJson, chatBody.model, { estInput, thinking: emitThinking() });
354
+ alog("done-json", JSON.stringify({ ms: Date.now() - t0, status, blocks: out.content.length, stop: out.stop_reason, usage: out.usage }));
355
+ return json(res, 200, out);
356
+ }
357
+ } catch (err) {
358
+ alog("execute-throw", String(err?.message || err).slice(0, 300));
359
+ if (!res.headersSent) return json(res, 502, anthropicError("api_error", String(err?.message || err)));
360
+ try { res.end(); } catch { /* ignore */ }
361
+ }
362
+ }
363
+
364
+ export async function countTokensHandler(ctx) {
365
+ const { req, res } = ctx;
366
+ let body;
367
+ try {
368
+ body = await readBody(req);
369
+ } catch {
370
+ return json(res, 400, anthropicError("invalid_request_error", "Invalid JSON body"));
371
+ }
372
+ const out = estimateTokens(body);
373
+ alog("count-tokens", JSON.stringify({ model: body?.model, input_tokens: out.input_tokens }));
374
+ return json(res, 200, out);
375
+ }