@xiaobuyu/nodesign 0.0.1 → 0.0.3

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 (358) hide show
  1. package/README.md +156 -106
  2. package/bin/nodesign.js +0 -0
  3. package/package.json +1 -1
  4. package/server/api/README.md +45 -45
  5. package/server/api/_guard.js +75 -75
  6. package/server/api/admin.js +187 -187
  7. package/server/api/assets/docx-page.js +103 -103
  8. package/server/api/assets/helpers.js +42 -22
  9. package/server/api/assets/notes.js +194 -144
  10. package/server/api/assets.js +891 -896
  11. package/server/api/board.js +134 -57
  12. package/server/api/browse.js +138 -138
  13. package/server/api/canvas.js +444 -444
  14. package/server/api/chatai.js +324 -324
  15. package/server/api/exports/build-standalone.js +980 -980
  16. package/server/api/exports/cards.js +118 -118
  17. package/server/api/exports/docx-pdf.js +46 -46
  18. package/server/api/exports/export-page.js +142 -142
  19. package/server/api/exports/handoff.js +147 -147
  20. package/server/api/exports.js +690 -690
  21. package/server/api/instruction.js +75 -75
  22. package/server/api/me.js +116 -116
  23. package/server/api/memory.js +185 -185
  24. package/server/api/pending-changes.js +366 -366
  25. package/server/api/plugins.js +164 -164
  26. package/server/api/projects.js +220 -220
  27. package/server/api/publish.js +64 -64
  28. package/server/api/recent.js +73 -73
  29. package/server/api/sessions.js +589 -589
  30. package/server/api/skills.js +77 -77
  31. package/server/api/standalone-fit.js +168 -168
  32. package/server/api/turn-compose.js +180 -180
  33. package/server/api/turn-inflight.js +44 -44
  34. package/server/auth/README.md +17 -17
  35. package/server/auth/middleware.js +138 -138
  36. package/server/auth/origin-guard.js +71 -71
  37. package/server/auth/session.js +108 -108
  38. package/server/auth/tier.js +113 -113
  39. package/server/auth/users-store.js +348 -348
  40. package/server/edit/README.md +29 -29
  41. package/server/engine/README.md +113 -113
  42. package/server/engine/agent/agent-shared.js +550 -550
  43. package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -4
  44. package/server/engine/agent/auto-mode-rules.js +78 -78
  45. package/server/engine/agent/context.js +418 -418
  46. package/server/engine/agent/events.js +465 -464
  47. package/server/engine/agent/hooks/canvas-validate.js +199 -199
  48. package/server/engine/agent/hooks/failure.js +120 -120
  49. package/server/engine/agent/hooks/file-events.js +75 -75
  50. package/server/engine/agent/hooks/lifecycle.js +233 -233
  51. package/server/engine/agent/hooks/post-canvas-focus.js +80 -80
  52. package/server/engine/agent/hooks/post-guidance.js +206 -206
  53. package/server/engine/agent/hooks/post-subagent-report.js +104 -104
  54. package/server/engine/agent/hooks/post-trim.js +58 -58
  55. package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -34
  56. package/server/engine/agent/hooks/pre-defaults.js +84 -84
  57. package/server/engine/agent/hooks/pre-injectors.js +277 -277
  58. package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -78
  59. package/server/engine/agent/hooks/pre-starter-files.js +61 -61
  60. package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -78
  61. package/server/engine/agent/hooks/site-validate.js +94 -94
  62. package/server/engine/agent/hooks/tool-prompts.js +31 -31
  63. package/server/engine/agent/hooks/turn-state-memory.js +56 -56
  64. package/server/engine/agent/hooks/user-prompt-submit.js +258 -215
  65. package/server/engine/agent/hooks.js +354 -354
  66. package/server/engine/agent/init-contract.js +69 -69
  67. package/server/engine/agent/isolation.js +159 -159
  68. package/server/engine/agent/model-table.js +292 -292
  69. package/server/engine/agent/plugin-loader.js +266 -266
  70. package/server/engine/agent/prompts/nodesign-prelude.md +388 -336
  71. package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -71
  72. package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -98
  73. package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -583
  74. package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -483
  75. package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -100
  76. package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -270
  77. package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -70
  78. package/server/engine/agent/prompts/tools/site-reference.md +106 -106
  79. package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -125
  80. package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -48
  81. package/server/engine/agent/session-loop.js +1035 -1024
  82. package/server/engine/agent/session-model.js +164 -164
  83. package/server/engine/agent/skill.js +204 -204
  84. package/server/engine/agent/task-events.js +78 -78
  85. package/server/engine/agents/ds-extractor.md +132 -132
  86. package/server/engine/agents/explorer.md +190 -190
  87. package/server/engine/agents/index.js +230 -230
  88. package/server/engine/agents/schemas/design-system.json +156 -156
  89. package/server/engine/agents/schemas/tweak-schema.json +113 -113
  90. package/server/engine/agents/tweak-proposer.md +127 -127
  91. package/server/engine/agents/vision-checker.md +254 -254
  92. package/server/engine/browse/capture.js +422 -422
  93. package/server/engine/browse/card.js +137 -137
  94. package/server/engine/browse/handover.js +59 -59
  95. package/server/engine/browse/page-digest.js +119 -119
  96. package/server/engine/browse/refs.js +196 -196
  97. package/server/engine/browse/registry.js +307 -307
  98. package/server/engine/browse/screencast.js +170 -170
  99. package/server/engine/browse/state.js +121 -121
  100. package/server/engine/chatai/chat-log.js +79 -79
  101. package/server/engine/chatai/index.js +193 -193
  102. package/server/engine/chatai/openai-compat.js +152 -152
  103. package/server/engine/chatai/orchestrate.js +309 -309
  104. package/server/engine/chatai/perform.js +40 -40
  105. package/server/engine/chatai/summarize.js +79 -79
  106. package/server/engine/mcp/capability-gate.js +52 -52
  107. package/server/engine/mcp/index.js +351 -339
  108. package/server/engine/mcp/param-sanitizer.js +142 -142
  109. package/server/engine/mcp/tools/arrange-on-board.js +124 -124
  110. package/server/engine/mcp/tools/artifact-session.js +278 -278
  111. package/server/engine/mcp/tools/browse-computer.js +421 -421
  112. package/server/engine/mcp/tools/browse-find-batch.js +183 -183
  113. package/server/engine/mcp/tools/browse-screenshot.js +157 -157
  114. package/server/engine/mcp/tools/browse.js +480 -480
  115. package/server/engine/mcp/tools/build-docx.js +101 -101
  116. package/server/engine/mcp/tools/clear-pending-changes.js +99 -99
  117. package/server/engine/mcp/tools/create-on-board.js +152 -155
  118. package/server/engine/mcp/tools/crystallize-skill.js +168 -168
  119. package/server/engine/mcp/tools/edit-sketch.js +167 -0
  120. package/server/engine/mcp/tools/explain-style.js +237 -237
  121. package/server/engine/mcp/tools/export-handoff.js +133 -133
  122. package/server/engine/mcp/tools/expose-tweaks.js +149 -149
  123. package/server/engine/mcp/tools/generate-image.js +842 -842
  124. package/server/engine/mcp/tools/get-computed-styles.js +164 -164
  125. package/server/engine/mcp/tools/get-pending-changes.js +205 -205
  126. package/server/engine/mcp/tools/h3box-ssh.js +84 -84
  127. package/server/engine/mcp/tools/helpers/acquire-page.js +82 -82
  128. package/server/engine/mcp/tools/helpers/motion-lab.js +554 -554
  129. package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -112
  130. package/server/engine/mcp/tools/helpers/perception-page.js +215 -215
  131. package/server/engine/mcp/tools/helpers/reference-download.js +103 -103
  132. package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -80
  133. package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -280
  134. package/server/engine/mcp/tools/highlight.js +68 -68
  135. package/server/engine/mcp/tools/list-pages.js +222 -222
  136. package/server/engine/mcp/tools/look-at-board.js +124 -0
  137. package/server/engine/mcp/tools/lookup-tags.js +59 -59
  138. package/server/engine/mcp/tools/navigate-to-page.js +60 -60
  139. package/server/engine/mcp/tools/organize-board.js +64 -64
  140. package/server/engine/mcp/tools/paint-still.js +434 -434
  141. package/server/engine/mcp/tools/pin-to-board.js +130 -130
  142. package/server/engine/mcp/tools/preview-deck.js +130 -130
  143. package/server/engine/mcp/tools/profile-scroll.js +305 -305
  144. package/server/engine/mcp/tools/publish-site.js +102 -102
  145. package/server/engine/mcp/tools/query-elements.js +195 -195
  146. package/server/engine/mcp/tools/read-board.js +190 -103
  147. package/server/engine/mcp/tools/read-document.js +93 -93
  148. package/server/engine/mcp/tools/read-page.js +264 -264
  149. package/server/engine/mcp/tools/read-tavern-json.js +143 -143
  150. package/server/engine/mcp/tools/read-user-view.js +59 -0
  151. package/server/engine/mcp/tools/record-decision.js +140 -140
  152. package/server/engine/mcp/tools/relate-on-board.js +112 -110
  153. package/server/engine/mcp/tools/report-issue.js +115 -115
  154. package/server/engine/mcp/tools/roll-film.js +266 -266
  155. package/server/engine/mcp/tools/screenshot-docx.js +138 -138
  156. package/server/engine/mcp/tools/screenshot-url.js +195 -195
  157. package/server/engine/mcp/tools/screenshot.js +583 -583
  158. package/server/engine/mcp/tools/sketch-on-board.js +337 -0
  159. package/server/engine/mcp/tools/tier-gate.js +96 -96
  160. package/server/engine/mcp/tools/trace-motion.js +215 -215
  161. package/server/engine/mcp/tools/web-search.js +548 -548
  162. package/server/engine/mcp/tools/write-on-board.js +130 -0
  163. package/server/engine/motion/inventory.js +349 -349
  164. package/server/engine/perception/session.js +235 -235
  165. package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -5
  166. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +199 -192
  167. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -667
  168. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -22
  169. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -25
  170. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -23
  171. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -21
  172. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -23
  173. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -21
  174. package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +169 -162
  175. package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -407
  176. package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -80
  177. package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +235 -228
  178. package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -78
  179. package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -268
  180. package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +330 -323
  181. package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -134
  182. package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -61
  183. package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -163
  184. package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -86
  185. package/server/engine/runs/active-runs.js +698 -698
  186. package/server/engine/runs/board-tasklist.js +130 -0
  187. package/server/engine/runs/live-turn.js +252 -252
  188. package/server/engine/runs/store.js +364 -364
  189. package/server/engine/runs/turn-relay.js +217 -217
  190. package/server/engine/runtime/workspace.js +164 -164
  191. package/server/index.js +3 -0
  192. package/server/lib/artifact-file-path.js +80 -80
  193. package/server/lib/artifact-target.js +284 -284
  194. package/server/lib/asset-refs.js +239 -239
  195. package/server/lib/async-queue.js +104 -104
  196. package/server/lib/auto-relations.js +130 -130
  197. package/server/lib/binding-types.js +72 -60
  198. package/server/lib/board-graph-export.js +191 -0
  199. package/server/lib/board-groups.js +167 -0
  200. package/server/lib/board-hero.js +76 -0
  201. package/server/lib/board-kind-sizes.js +85 -62
  202. package/server/lib/board-relations.js +133 -133
  203. package/server/lib/browse-proxy.js +180 -180
  204. package/server/lib/canvas-id.js +36 -36
  205. package/server/lib/chalk.js +121 -0
  206. package/server/lib/cover.js +227 -227
  207. package/server/lib/danbooru-tags.js +291 -291
  208. package/server/lib/doc-extract.js +156 -156
  209. package/server/lib/docx/build-from-source.js +260 -260
  210. package/server/lib/docx/build.js +490 -490
  211. package/server/lib/docx/dump-styles.js +287 -287
  212. package/server/lib/docx/fonts/nodesign-cjk.conf +164 -164
  213. package/server/lib/docx/merge-runs.js +129 -129
  214. package/server/lib/docx/numbering.js +218 -218
  215. package/server/lib/docx/order.js +152 -152
  216. package/server/lib/docx/rawzip.js +172 -172
  217. package/server/lib/docx/render.js +92 -92
  218. package/server/lib/docx/text-lint.js +225 -225
  219. package/server/lib/docx/tokens.js +335 -335
  220. package/server/lib/docx/units.js +29 -29
  221. package/server/lib/docx/xml.js +291 -291
  222. package/server/lib/docx-pages.js +187 -187
  223. package/server/lib/export-collect.js +289 -289
  224. package/server/lib/export-package.js +210 -210
  225. package/server/lib/html-srcset.js +145 -145
  226. package/server/lib/image-variant.js +460 -460
  227. package/server/lib/ingress/forward-openai-chat.js +305 -305
  228. package/server/lib/ingress/openai-chat.js +491 -491
  229. package/server/lib/ingress/session-notice.js +66 -66
  230. package/server/lib/ingress/session-routes.js +68 -68
  231. package/server/lib/ingress/slot-probe.js +130 -130
  232. package/server/lib/ingress/upstream-billing.js +52 -52
  233. package/server/lib/ingress/upstream-fail-streak.js +72 -72
  234. package/server/lib/ingress/upstream-truncation.js +48 -48
  235. package/server/lib/issues-store.js +182 -182
  236. package/server/lib/kinds/deck.js +72 -72
  237. package/server/lib/kinds/docx.js +283 -283
  238. package/server/lib/kinds/file-kinds.js +113 -113
  239. package/server/lib/kinds/index.js +202 -202
  240. package/server/lib/kinds/site.js +161 -161
  241. package/server/lib/model-ingress.js +543 -543
  242. package/server/lib/moderation.js +255 -255
  243. package/server/lib/notice-store.js +103 -103
  244. package/server/lib/plugin-install.js +104 -104
  245. package/server/lib/plugin-validator.js +721 -721
  246. package/server/lib/publish-store.js +115 -115
  247. package/server/lib/quick-summary.js +52 -52
  248. package/server/lib/quota.js +270 -270
  249. package/server/lib/rate-window.js +29 -29
  250. package/server/lib/reference-assets.js +92 -92
  251. package/server/lib/region-shot.js +135 -135
  252. package/server/lib/safe-path.js +73 -73
  253. package/server/lib/sdk-session.js +33 -33
  254. package/server/lib/showcase-store.js +92 -92
  255. package/server/lib/site-publish.js +465 -465
  256. package/server/lib/sketch-layout.js +277 -0
  257. package/server/lib/ssrf-guard.js +432 -432
  258. package/server/lib/task-scan.js +158 -158
  259. package/server/lib/tavern-json.js +154 -154
  260. package/server/lib/video-variant.js +237 -237
  261. package/server/lib/workspace-path.js +33 -33
  262. package/server/ops/fix-sdk-musl.mjs +40 -40
  263. package/server/ops/install-macos-fonts.sh +114 -114
  264. package/server/ops/macos-fonts.conf +336 -336
  265. package/server/ops/sandbox-shim/bwrap +57 -57
  266. package/server/projects/assets-summary.js +119 -119
  267. package/server/projects/auto-name.js +53 -53
  268. package/server/projects/board-limits.js +12 -0
  269. package/server/projects/board-sanitize.js +252 -0
  270. package/server/projects/board-store.js +507 -640
  271. package/server/projects/move-entry.js +103 -103
  272. package/server/projects/store.js +270 -270
  273. package/server/projects/ui-config.js +60 -54
  274. package/server/projects/viewpoint-store.js +78 -0
  275. package/server/projects/workspace-templates.js +53 -53
  276. package/server/projects/workspace.js +1025 -1025
  277. package/server/runtime/local-config.js +177 -177
  278. package/server/runtime/profile.js +72 -72
  279. package/server/services/rembg-service.py +334 -334
  280. package/server/shared/README.md +25 -25
  281. package/server/shared/deck.js +54 -54
  282. package/server/shared/time.js +49 -49
  283. package/server/style-pipeline/README.md +53 -53
  284. package/server/ws/broker.js +39 -36
  285. package/server/ws/browse-channel.js +177 -177
  286. package/server/ws/index.js +386 -386
  287. package/web/README.md +89 -89
  288. package/web/dist/assets/BrowserWindow-P_vIdh-G.js +11 -0
  289. package/web/dist/assets/{DeckWindow-4G0xX7P3.js → DeckWindow-BpLv5082.js} +3 -3
  290. package/web/dist/assets/{DocxWindow-BXQsoPkh.js → DocxWindow-DSAU1-tD.js} +4 -4
  291. package/web/dist/assets/MermaidBlock-DR1GikCv.js +313 -0
  292. package/web/dist/assets/SiteWindow-92uBuRRR.js +18 -0
  293. package/web/dist/assets/abnfDiagram-VCTEODGH-uA8Esix4.js +1 -0
  294. package/web/dist/assets/arc-D_khH_xN.js +1 -0
  295. package/web/dist/assets/architectureDiagram-5GKGNRK7-DydJHqOt.js +36 -0
  296. package/web/dist/assets/blockDiagram-NRAW4CY4-B7wBbKiW.js +129 -0
  297. package/web/dist/assets/c4Diagram-UCG6FXSJ-6mrH0x7Y.js +38 -0
  298. package/web/dist/assets/channel-DGkccMO1.js +1 -0
  299. package/web/dist/assets/chunk-2Q5K7J3B-Cuikho5Q.js +1 -0
  300. package/web/dist/assets/chunk-5VM5RSS4-3-KZ5AUz.js +15 -0
  301. package/web/dist/assets/chunk-F27PBJKO-Bkwq2gGU.js +1 -0
  302. package/web/dist/assets/chunk-G27WJ6UU-BLyQzdGE.js +231 -0
  303. package/web/dist/assets/chunk-JWPE2WC7-B7HxxQds.js +1 -0
  304. package/web/dist/assets/chunk-LCL6LL3I-CtXrnUwF.js +206 -0
  305. package/web/dist/assets/chunk-POPQ4Y6H-dxddZlXt.js +1 -0
  306. package/web/dist/assets/chunk-SVP7TREG-DpkmrgHn.js +88 -0
  307. package/web/dist/assets/chunk-XXDRQBXY-BwZ7RbpQ.js +1 -0
  308. package/web/dist/assets/classDiagram-DTDB5LWJ-BGscw3OD.js +1 -0
  309. package/web/dist/assets/classDiagram-v2-JRS7N3AN-BGscw3OD.js +1 -0
  310. package/web/dist/assets/cose-bilkent-JH36ORCC-DGFOsMUt.js +1 -0
  311. package/web/dist/assets/cynefin-OW5HDTMX-DUwn3ehS.js +178 -0
  312. package/web/dist/assets/cynefinDiagram-5FMLGOSQ-DlLAOZ9x.js +62 -0
  313. package/web/dist/assets/cytoscape.esm-BjAbmLdb.js +331 -0
  314. package/web/dist/assets/dagre-3AP2YEHR-CyourLfz.js +4 -0
  315. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  316. package/web/dist/assets/diagram-S7CK7UJ4-BTMbx2np.js +30 -0
  317. package/web/dist/assets/diagram-UQ7AKVKN-UK94_1-P.js +41 -0
  318. package/web/dist/assets/diagram-VSXAHHWV-C7zS-v9v.js +3 -0
  319. package/web/dist/assets/diagram-VX7I27RA-DKJRa6CK.js +24 -0
  320. package/web/dist/assets/diagram-Z3DM3KII-kUbAfGAp.js +24 -0
  321. package/web/dist/assets/ebnfDiagram-PWID7BFC-CVW16_vb.js +1 -0
  322. package/web/dist/assets/erDiagram-SSCWMZ5O-BRQMFQ5y.js +99 -0
  323. package/web/dist/assets/flowDiagram-A5DVABFB-S34LpLtK.js +168 -0
  324. package/web/dist/assets/ganttDiagram-EL5Y4UJY-BFiqk1-G.js +292 -0
  325. package/web/dist/assets/gitGraphDiagram-WWUBYQGX-LZsrdyyv.js +106 -0
  326. package/web/dist/assets/index-GAamh1p4.js +2152 -0
  327. package/web/dist/assets/infoDiagram-RXCK75RN-0DvN6KIp.js +2 -0
  328. package/web/dist/assets/init-Gi6I4Gst.js +1 -0
  329. package/web/dist/assets/ishikawaDiagram-5VMMS53U-CaY3A-Bx.js +70 -0
  330. package/web/dist/assets/journeyDiagram-EYS64GPL-BBeiaCrj.js +139 -0
  331. package/web/dist/assets/kanban-definition-3QL26DDD-ux42DRXQ.js +89 -0
  332. package/web/dist/assets/layout-CnXiF_NB.js +1 -0
  333. package/web/dist/assets/linear-BMW0u6G6.js +1 -0
  334. package/web/dist/assets/mindmap-definition-FBJOCRG2-hUxSpdKn.js +96 -0
  335. package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  336. package/web/dist/assets/pegDiagram-XKGWAZYB-aCaQPH0w.js +1 -0
  337. package/web/dist/assets/{pending-edit-apply-DTjo2geW.js → pending-edit-apply-AcdOQ9Ij.js} +6 -6
  338. package/web/dist/assets/pieDiagram-E7YTZNPT-CmOEZ8zg.js +39 -0
  339. package/web/dist/assets/quadrantDiagram-AXDQQJYC-VW6fAhsV.js +7 -0
  340. package/web/dist/assets/railroadDiagram-O6MQD6OU-CKJ7Ba5K.js +1 -0
  341. package/web/dist/assets/requirementDiagram-EFPCY7ZU-pNGkq9Vw.js +84 -0
  342. package/web/dist/assets/{rotate-cw-LMT3x1Zj.js → rotate-cw-KAfChbGV.js} +1 -1
  343. package/web/dist/assets/sankeyDiagram-P5KCCOFB-BIMmDTfZ.js +40 -0
  344. package/web/dist/assets/sequenceDiagram-WJ2MYXX4-BBW7wzKY.js +162 -0
  345. package/web/dist/assets/sizeCapture-X5ZJPWSS-DdkXmLYR.js +1 -0
  346. package/web/dist/assets/{square-dashed-mouse-pointer-CBoAkfhi.js → square-dashed-mouse-pointer-CDUlhUP7.js} +1 -1
  347. package/web/dist/assets/stateDiagram-HBIQ2CUA-W313Uz16.js +1 -0
  348. package/web/dist/assets/stateDiagram-v2-4QOOHH4V-DDhJBZzf.js +1 -0
  349. package/web/dist/assets/swimlanes-XN3QIQJK-C29TL26K.js +1 -0
  350. package/web/dist/assets/swimlanesDiagram-VK2B7HYN-BfN-Xdmu.js +8 -0
  351. package/web/dist/assets/timeline-definition-24CTP7MA-CF0JMObX.js +120 -0
  352. package/web/dist/assets/vennDiagram-4TSXK5OY-DrDFsaH3.js +34 -0
  353. package/web/dist/assets/wardleyDiagram-VM6X3IG4-CVrnRIS_.js +78 -0
  354. package/web/dist/assets/xychartDiagram-S5SC5T6Z-BvkN1zsT.js +7 -0
  355. package/web/dist/index.html +17 -17
  356. package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +0 -11
  357. package/web/dist/assets/SiteWindow-D6eS-2gM.js +0 -18
  358. package/web/dist/assets/index-Cbl3ATvp.js +0 -2125
@@ -1,543 +1,543 @@
1
- /**
2
- * server/lib/model-ingress.js — Anthropic 范式通用模型入口(2026-08-19,
3
- * 前身 binary-fixup-proxy.js)。
4
- *
5
- * ## 定位
6
- *
7
- * SDK binary 永远说 Anthropic Messages 协议;所有非订阅模型都从这里进。
8
- * 入口按 **请求 body.model 查表**(model-context.js 的 resolveWireModel)
9
- * 决定发往哪个上游、换哪把钥匙、开哪些修补 —— 无会话状态、无进程级
10
- * "当前模型" env(旧版的 NODESIGN_CURRENT_APP_MODEL 在多会话不同模型时
11
- * 会互相覆盖,已随 NoDesk 包装一起退役)。
12
- *
13
- * ## 每请求流水线
14
- *
15
- * 1. 剥 /__nd/<sessionId> 路径前缀(只用于日志归属)
16
- * 2. body.model 反查路由(appModel / sdkAlias / 剥[1m]的 alias 都认);
17
- * 查不到 → 502 fail-loud,绝不盲转发(盲转发 = 烧错通路的钱且无人知晓)。
18
- * 注册过的 API 会话只认自己那行 + 自己的 fast 行,其它名字改道 fast 兜底
19
- * (未知名 = SDK helper;在表里但属于别的行 = 撞名雷,见 resolveSessionWire)
20
- * 3. count_tokens:上游没有该端点(表里标了 / 404 实测过)→ 本地估算短路
21
- * 4. 修补:model 还原成上游真名 · thinking strip/enabled8k · tool_result
22
- * 图片提升到顶层(Kimi 与 Gemini 桥都丢 tool_result 图,08-19 探针实锤)·
23
- * 超长边图片下采样
24
- * 5. 换钥匙:上游钥匙按条目取(外部插槽 .key 内联 > env UPSTREAMS[].keyEnv),按 authStyle 发;
25
- * binary 带来的 ANTHROPIC_API_KEY 是占位符,一律丢弃
26
- * 6. 转发(SSE 直接 pipe,不解析响应流)
27
- *
28
- * ## 已知缺口(记录在案,别当没有)
29
- *
30
- * - 反重力通道流式 stop_reason 恒=end_turn 的病(tool_use 块在、收尾说错话)
31
- * 需要解析改写 SSE 才能修 —— 那族通道只当玩具,不为它上复杂度。
32
- * - prompt cache 不过桥(探针实测 cache 字段恒 0),长会话经济性靠模型价差硬扛。
33
- */
34
-
35
- import http from 'node:http';
36
- import https from 'node:https';
37
- import sharp from 'sharp';
38
- import { resolveWireModel, UPSTREAMS } from '../engine/agent/model-context.js';
39
- import { resolveSessionWire, fallbackLogged } from './ingress/session-routes.js';
40
- import { forwardOpenAIChat } from './ingress/forward-openai-chat.js';
41
- import { failStreaks, exhaustedErrorBody } from './ingress/upstream-fail-streak.js';
42
- import { noteUpstreamBilling } from './ingress/upstream-billing.js';
43
- import { noteUpstreamTruncation } from './ingress/upstream-truncation.js';
44
- import { noticeSession } from './ingress/session-notice.js';
45
-
46
- // ── 出站连接池 ──
47
- // ⛔ 08-20 生产真踩:本地盒子(authStyle 'none' = 环回 SSH 隧道)走 Node 默认
48
- // globalAgent,而它 **keepAlive 默认开着**。llama-server 几秒就关掉空闲连接,
49
- // 那个 FIN 要先穿过 SSH 隧道才到 Node —— 窗口里 Node 会从池子里挑一条**其实已经
50
- // 死了的 socket** 发请求,结果就是 `ECONNRESET: socket hang up` 刷屏(一分钟几百条),
51
- // 而盒子那头 `ss` 看到的连接数是 0(两边对同一条连接的死活认知不一致,是识别信号)。
52
- // 隧道内建连接本来就便宜(没有 TLS 握手),所以本地上游一律不复用连接。
53
- // 远端上游(中转站/HTTPS)保持 keep-alive:那边握手贵,且没有隧道拖慢 FIN。
54
- const AGENTS = new Map();
55
- function agentFor(wire, useHttps) {
56
- if (wire.upstream.authStyle !== 'none') return undefined; // 远端:用默认 globalAgent
57
- const key = useHttps ? 'https' : 'http';
58
- if (!AGENTS.has(key)) AGENTS.set(key, new (useHttps ? https : http).Agent({ keepAlive: false }));
59
- return AGENTS.get(key);
60
- }
61
-
62
- let _instance = null;
63
-
64
- const PREFIX_RE = /^\/__nd\/([^/]+)(\/.*)$/;
65
-
66
- // Vision 下采样阈值(长边像素)。1568 = Anthropic token 优化阈值,也是多数
67
- // 中转网关安全线(mili-logo 2500×2500 曾触发网关 400)。
68
- const VISION_MAX_DIM = (() => {
69
- const n = Number(process.env.NODESIGN_VISION_MAX_DIM);
70
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1568;
71
- })();
72
-
73
- // count_tokens capability 运行时缓存:upstreamId → false(404 实测确认没有)。
74
- // 表里 countTokens:false 的上游直接短路,true 的先转发、404 后降级并记住。
75
- const countTokensDead = new Set();
76
-
77
- // 会话级路由(注册 / 注销 / 决策)住 ingress/session-routes.js;这里转出口给 session-loop 与测试
78
- export { registerIngressSession, unregisterIngressSession, resolveSessionWire } from './ingress/session-routes.js';
79
-
80
- /**
81
- * 启动入口(幂等单例;路由是 per-request 的,单例不再锁死目标)。
82
- * @returns {Promise<{ baseUrl: string, close: () => Promise<void> }>}
83
- */
84
- export async function getOrStartIngress() {
85
- if (_instance) return _instance;
86
-
87
- const server = http.createServer((req, res) => {
88
- const chunks = [];
89
- req.on('data', (c) => chunks.push(c));
90
- req.on('end', async () => {
91
- try {
92
- await handleRequest(req, res, Buffer.concat(chunks));
93
- } catch (err) {
94
- console.error(`[model-ingress] handler error: ${err?.stack || err?.message || err}`);
95
- try { res.writeHead(502); res.end(`ingress handler error: ${err?.message || 'unknown'}`); } catch { /* ignore */ }
96
- }
97
- });
98
- req.on('error', (err) => {
99
- console.error(`[model-ingress] request error: ${err.message}`);
100
- try { res.writeHead(400); res.end(); } catch { /* ignore */ }
101
- });
102
- });
103
-
104
- await new Promise((resolve, reject) => {
105
- const onError = (err) => { server.removeListener('listening', onListening); reject(err); };
106
- const onListening = () => { server.removeListener('error', onError); resolve(); };
107
- server.once('error', onError);
108
- server.once('listening', onListening);
109
- server.listen(0, '127.0.0.1');
110
- });
111
-
112
- const baseUrl = `http://127.0.0.1:${server.address().port}`;
113
- console.log(`[model-ingress] listening on ${baseUrl} (upstreams: ${Object.keys(UPSTREAMS).join(', ')})`);
114
- _instance = { baseUrl, close: () => new Promise((r) => server.close(() => r())), server };
115
- return _instance;
116
- }
117
-
118
- export async function stopIngress() {
119
- if (!_instance) return;
120
- await _instance.close();
121
- _instance = null;
122
- }
123
-
124
- async function handleRequest(req, res, bodyBuf) {
125
- // 剥 /__nd/<sessionId> 前缀(日志归属用)
126
- let sessionTag = null;
127
- let origPath = req.url;
128
- const m = PREFIX_RE.exec(req.url);
129
- if (m) { sessionTag = decodeURIComponent(m[1]); origPath = m[2]; }
130
- const sidShort = sessionTag ? sessionTag.slice(0, 8) : '-';
131
-
132
- if (!(req.method === 'POST' && /^\/v1\/messages\b/.test(origPath))) {
133
- res.writeHead(502, { 'Content-Type': 'text/plain' });
134
- res.end(`model-ingress: unsupported ${req.method} ${origPath}`);
135
- return;
136
- }
137
-
138
- let parsed;
139
- try {
140
- parsed = JSON.parse(bodyBuf.toString('utf8'));
141
- } catch {
142
- // 没有 model 就没法路由 —— fail-loud 而不是盲转发
143
- res.writeHead(502, { 'Content-Type': 'text/plain' });
144
- res.end('model-ingress: body is not JSON, cannot route');
145
- return;
146
- }
147
-
148
- const routed = resolveSessionWire(parsed?.model, sessionTag);
149
- const wire = routed.wire;
150
- if (wire && routed.reason !== 'table') {
151
- const logKey = `${sessionTag}:${parsed?.model}`;
152
- if (!fallbackLogged.has(logKey)) {
153
- fallbackLogged.add(logKey);
154
- if (routed.reason === 'collision') {
155
- console.warn(`[model-ingress] sid=${sidShort} ⛔ 撞名:model '${parsed?.model}' 属于别的行(${routed.collidesWith}),本会话是 ${routed.sessionModel} —— 不跨行转发,改道会话 fast(${routed.fastModel})`);
156
- } else {
157
- console.warn(`[model-ingress] sid=${sidShort} 未知 model '${parsed?.model}' → 会话 fast 兜底(${routed.fastModel})`);
158
- }
159
- }
160
- }
161
- if (!wire) {
162
- res.writeHead(502, { 'Content-Type': 'text/plain' });
163
- res.end(`model-ingress: no route for model '${parsed?.model}' — 该名字不在 model-context.js 的 API 表里`);
164
- console.warn(`[model-ingress] sid=${sidShort} 未知 model '${parsed?.model}',已拒绝(fail-loud)`);
165
- return;
166
- }
167
-
168
- // authStyle 'none' = 无鉴权上游(本地 llama-server 走环回隧道),不需要钥匙
169
- const needKey = wire.upstream.authStyle !== 'none';
170
- // 钥匙取用点(全入口唯一一处):外部插槽(runtime/local-config.js)把钥匙写在条目里;内置行走 env
171
- const key = needKey ? (wire.upstream.key || (wire.upstream.keyEnv ? process.env[wire.upstream.keyEnv] : null)) : null;
172
- if (needKey && !key) {
173
- res.writeHead(502, { 'Content-Type': 'text/plain' });
174
- res.end(`model-ingress: 上游钥匙未配置(${wire.upstream.keyEnv ? `env ${wire.upstream.keyEnv} 为空` : `上游 ${wire.upstreamId} 没填 key`})`);
175
- return;
176
- }
177
-
178
- const isCountTokens = /^\/v1\/messages\/count_tokens\b/.test(origPath);
179
- // CLI 在**回合之间**也用主模型打请求(猜你想问 / 标题生成),ingress 分不出角色(都是 main)。
180
- // 它们的半截不该触发续接:那是给用户的建议词,不是 agent 的回答,接下去也没意义,
181
- // 而且标记会串到下一个回合头上(fable 评审 P2;真路径探针里 SUGGESTION MODE 每回合都来一发)。
182
- // 认的是 CLI 自己写死的提示词开头,认不出来最多是少一道保险(还有回合内新鲜度判据兜底)。
183
- const isSuggestionQuery = (() => {
184
- try {
185
- const last = parsed?.messages?.[parsed.messages.length - 1];
186
- const txt = typeof last?.content === 'string' ? last.content : JSON.stringify(last?.content || '');
187
- return txt.includes('SUGGESTION MODE');
188
- } catch { return false; }
189
- })();
190
-
191
- // 会话连续失败上限(upstream-fail-streak.js,僵尸 run 案):上游持续死时 CLI 对 5xx 无上限退避重试,
192
- // 一个回合能挂一小时。到上限就回 400(CLI 不重试、回合以 is_error 收场、文案到用户、会话不死),
193
- // 计数归零让用户下次再发有新机会。count_tokens 不参与(它不是循环的燃料)。
194
- // 计数按 sid+角色分桶:helper(标题/分类器)一次成功不能把主行攒的计数清零(08-21 评审抓的洞)
195
- const streakKey = sessionTag ? `${sessionTag}:${routed.role || 'main'}` : null;
196
- if (!isCountTokens && failStreaks.exhausted(streakKey)) {
197
- const { n, reason } = failStreaks.consume(streakKey);
198
- const body = JSON.stringify(exhaustedErrorBody({ label: wire.upstream?.label || wire.upstreamId, n, reason }));
199
- console.warn(`[model-ingress] sid=${sidShort} role=${routed.role} upstream=${wire.upstreamId} 连续失败 ${n} 次(${reason})→ 400 止损,计数归零`);
200
- res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': String(Buffer.byteLength(body)) });
201
- res.end(body);
202
- return;
203
- }
204
- const noteOutcome = (ok, reason) => {
205
- if (isCountTokens) return;
206
- const n = failStreaks.note(streakKey, ok, reason);
207
- // 上游抖的时候会话里什么都不动(CLI 在退避重试,一次 503 能挂 50~140 秒)—— 推一句人话,
208
- // 让用户知道是在重试而不是卡死,等不及可以自己点停止。节流住在 session-notice.js。
209
- if (!ok && routed.role !== 'helper') {
210
- const label = wire.upstream?.label || wire.upstreamId;
211
- noticeSession(sessionTag, {
212
- key: 'upstream_retry',
213
- text: `${label} 上游繁忙(${reason || '未知'}),正在自动重试第 ${n} 次;等不及可以点停止。`,
214
- priority: 'warn',
215
- });
216
- }
217
- };
218
-
219
- // count_tokens:上游没有该端点 → 本地估算短路(SDK 内部窗口计数要有数,
220
- // 否则 ContextUsageBar 永远"等待"、80% 预警从不触发 —— DMXAPI 时代真踩过)
221
- const upstreamHasCount = wire.upstream.countTokens !== false && !countTokensDead.has(wire.upstreamId);
222
- if (isCountTokens && !upstreamHasCount) {
223
- const respBody = JSON.stringify({ input_tokens: estimateInputTokens(parsed) });
224
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': String(Buffer.byteLength(respBody)) });
225
- res.end(respBody);
226
- return;
227
- }
228
-
229
- // 修补流水线(count_tokens 也要 model 还原,其余修补无害)
230
- await transformForUpstream(parsed, wire);
231
-
232
- // 协议分岔:上游说 OpenAI chat(Zen 等)→ 转换层;其余透传 Anthropic
233
- if (wire.protocol === 'openai-chat') {
234
- const target = new URL(wire.upstream.baseUrl);
235
- // helper 请求降档:主行想多少归主行,helper 一句话的活用 helperReasoningEffort(默认 low)
236
- const wireFwd = routed.role === 'helper' && wire.helperReasoningEffort ? { ...wire, reasoningEffort: wire.helperReasoningEffort } : wire;
237
- forwardOpenAIChat({ parsed, wire: wireFwd, key, res, sidShort, target, path: joinPath(target.pathname, '/chat/completions'), agent: agentFor(wire, target.protocol === 'https:'), onOutcome: noteOutcome,
238
- // 上游自报费用按会话 × appModel 累加(helper 请求记到 helper 行头上),session-loop 结账时取走
239
- onBilling: (info) => noteUpstreamBilling(sessionTag, wireFwd.appModel, info),
240
- // 「说到一半被掐」标记:session-loop 收到 result 时取走,有标记就自动续接一轮(upstream-truncation.js)。
241
- // ⚠️ helper 一律不报(连"收得完整"也不报):它就一句话的活不值得续接,而报 null 会把主行刚记下的
242
- // 标记清掉(标题/摘要那发常常紧跟在主回合后面)。
243
- onTruncated: (reason) => {
244
- if (routed.role === 'helper' || isCountTokens || isSuggestionQuery) return;
245
- noteUpstreamTruncation(sessionTag, reason, { appModel: wireFwd.appModel });
246
- },
247
- // 就地重发时说一声:那期间会话里什么都不动,用户只看到一个转不停的绿点(跟"上游繁忙"同一条通道)
248
- onNotice: (text) => {
249
- if (routed.role === 'helper') return; // helper 的重发用户不需要知道
250
- noticeSession(sessionTag, { key: 'upstream_retry', text, priority: 'warn' });
251
- } });
252
- return;
253
- }
254
- const outBody = Buffer.from(JSON.stringify(parsed), 'utf8');
255
-
256
- // 换钥匙 + 头处理
257
- const target = new URL(wire.upstream.baseUrl);
258
- const useHttps = target.protocol === 'https:';
259
- const headers = { ...req.headers, host: target.hostname };
260
- delete headers['x-api-key'];
261
- delete headers['authorization'];
262
- if (wire.upstream.authStyle === 'bearer') headers['authorization'] = `Bearer ${key}`;
263
- else if (needKey) headers['x-api-key'] = key;
264
- headers['content-length'] = String(outBody.length);
265
-
266
- const proxyReq = (useHttps ? https : http).request({
267
- hostname: target.hostname,
268
- port: target.port || (useHttps ? 443 : 80),
269
- path: joinPath(target.pathname, origPath),
270
- method: 'POST',
271
- headers,
272
- agent: agentFor(wire, useHttps),
273
- }, (proxyRes) => {
274
- // count_tokens 404 → 降级本地估算并记住这个上游没有该端点
275
- if (isCountTokens && (proxyRes.statusCode === 404 || proxyRes.statusCode === 405)) {
276
- countTokensDead.add(wire.upstreamId);
277
- proxyRes.resume(); // 丢弃上游响应体
278
- const respBody = JSON.stringify({ input_tokens: estimateInputTokens(parsed) });
279
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': String(Buffer.byteLength(respBody)) });
280
- res.end(respBody);
281
- console.warn(`[model-ingress] ${wire.upstreamId} 没有 count_tokens(404),已降级本地估算并缓存`);
282
- return;
283
- }
284
- // 上游 4xx/5xx:console.warn 一行 status + model + body 前 200 字,PM2 日志直接看到根因
285
- if (proxyRes.statusCode >= 400) {
286
- const respChunks = [];
287
- proxyRes.on('data', (c) => respChunks.push(c));
288
- proxyRes.on('end', () => {
289
- const preview = Buffer.concat(respChunks).slice(0, 200).toString('utf8').replace(/\s+/g, ' ');
290
- console.warn(`[model-ingress] sid=${sidShort} upstream=${wire.upstreamId} ${proxyRes.statusCode} model=${wire.wireModel} body=${preview}`);
291
- });
292
- // 透传路:5xx 记一次失败(4xx 是请求本身的问题,CLI 不重试,不记);2xx 算成功
293
- if (proxyRes.statusCode >= 500) noteOutcome(false, `HTTP ${proxyRes.statusCode}`);
294
- } else {
295
- noteOutcome(true);
296
- }
297
- res.writeHead(proxyRes.statusCode, proxyRes.headers);
298
- proxyRes.pipe(res);
299
- });
300
-
301
- proxyReq.on('error', (err) => {
302
- const detail = err.code ? `${err.code}: ${err.message}` : err.message;
303
- console.error(`[model-ingress] forward error (${wire.upstreamId}): ${detail}`);
304
- noteOutcome(false, `forward: ${detail}`);
305
- try { res.writeHead(502); res.end(`ingress forward error: ${detail}`); } catch { /* ignore */ }
306
- });
307
-
308
- proxyReq.write(outBody);
309
- proxyReq.end();
310
- }
311
-
312
- /**
313
- * 按路由行修补请求 body(原地改)。导出供单测直接喂假 body 验证。
314
- *
315
- * @param {object} parsed Anthropic Messages body
316
- * @param {object} wire resolveWireModel 的返回
317
- * @returns {Promise<boolean>} 是否有改动
318
- */
319
- export async function transformForUpstream(parsed, wire) {
320
- let mutated = false;
321
-
322
- // model 还原成上游真名(SDK 发的是 spoof alias 或 appModel)
323
- if (parsed.model !== wire.wireModel) {
324
- parsed.model = wire.wireModel;
325
- mutated = true;
326
- }
327
-
328
- // thinking:'strip' = 删字段让上游自决(Gemini thinking 关不掉、参数过桥行为
329
- // 未知);'enabled8k' = adaptive 改写成 enabled+budget(Kimi 实测 adaptive =
330
- // 0 thinking blocks)
331
- if (wire.thinking === 'strip') {
332
- if ('thinking' in parsed) { delete parsed.thinking; mutated = true; }
333
- } else if (wire.thinking === 'enabled8k') {
334
- if (parsed.thinking?.type === 'adaptive') {
335
- parsed.thinking = { type: 'enabled', budget_tokens: 8192 };
336
- mutated = true;
337
- }
338
- }
339
-
340
- // tool_result 里的图提升到 user message 顶层(丢图桥的必修 shim)
341
- if (wire.liftImages && Array.isArray(parsed.messages)) {
342
- if (liftImagesFromToolResult(parsed.messages)) mutated = true;
343
- }
344
-
345
- // 图片归一:长边超限下采样(所有上游统一做,安全无害)+ 把上游解不开的格式
346
- // 转码过去(只有声明了 imageFormats 的上游才做,见 model-context 那张表)
347
- if (Array.isArray(parsed.messages)) {
348
- const allowed = wire.upstream?.imageFormats || null;
349
- if (await normalizeImages(parsed.messages, VISION_MAX_DIM, allowed)) mutated = true;
350
- }
351
-
352
- return mutated;
353
- }
354
-
355
- /**
356
- * 把 tool_result.content 里的 image block 提到外层 user message content 顶层;
357
- * 原位置替换为占位文本。Kimi S8 时代写的,08-19 探针证明 Gemini 桥同样需要。
358
- */
359
- export function liftImagesFromToolResult(messages) {
360
- let mutated = false;
361
- for (const msg of messages) {
362
- if (msg?.role !== 'user' || !Array.isArray(msg.content)) continue;
363
- const liftedImages = [];
364
- for (const block of msg.content) {
365
- if (block?.type !== 'tool_result' || !Array.isArray(block.content)) continue;
366
- block.content = block.content.map((inner) => {
367
- if (inner?.type === 'image' && inner.source?.data) {
368
- liftedImages.push({ ...inner });
369
- mutated = true;
370
- return {
371
- type: 'text',
372
- text: '[image lifted to end of this message for upstream vision compat — see image block below]',
373
- };
374
- }
375
- return inner;
376
- });
377
- }
378
- if (liftedImages.length > 0) msg.content.push(...liftedImages);
379
- }
380
- return mutated;
381
- }
382
-
383
- /**
384
- * 图片归一:扫所有 image block(user msg 顶层 + tool_result 内嵌),按需下采样
385
- * 和/或转码。两件事共用一次遍历也共用一条 sharp 管线 —— 拆成两个函数就是把这段
386
- * 嵌套遍历抄第二份,而它已经是"顶层 + tool_result 内嵌"两层的形状了。
387
- *
388
- * fail-soft:单张图 sharp 抛错 → warn 后保留原图透传,不阻断整 turn。
389
- *
390
- * @param {number} maxDim 长边上限,超了才 resize
391
- * @param {string[]|null} allowed 上游解得开的 media_type 白名单;null = 不限
392
- */
393
- async function normalizeImages(messages, maxDim, allowed) {
394
- let mutated = false;
395
- for (const msg of messages) {
396
- if (!Array.isArray(msg?.content)) continue;
397
- for (let i = 0; i < msg.content.length; i++) {
398
- const block = msg.content[i];
399
- if (block?.type === 'image') {
400
- const replaced = await maybeNormalizeImageBlock(block, maxDim, allowed);
401
- if (replaced) { msg.content[i] = replaced; mutated = true; }
402
- }
403
- if (block?.type === 'tool_result' && Array.isArray(block.content)) {
404
- for (let j = 0; j < block.content.length; j++) {
405
- const inner = block.content[j];
406
- if (inner?.type === 'image') {
407
- const replaced = await maybeNormalizeImageBlock(inner, maxDim, allowed);
408
- if (replaced) { block.content[j] = replaced; mutated = true; }
409
- }
410
- }
411
- }
412
- }
413
- }
414
- return mutated;
415
- }
416
-
417
- const IMAGE_WRITERS = {
418
- 'image/png': (p) => p.png({ compressionLevel: 9 }),
419
- 'image/jpeg': (p) => p.jpeg({ quality: 90, mozjpeg: true }),
420
- 'image/webp': (p) => p.webp({ quality: 85 }),
421
- };
422
-
423
- /**
424
- * 单图归一。两个触发条件,满足任一才动(都不满足返 null 原样透传):
425
- * - 长边超过 maxDim → 下采样
426
- * - media_type 不在上游的 imageFormats 里 → 转码
427
- *
428
- * 转码目标按有没有 alpha 分:有 alpha 走 png(保住透明),没有走 jpeg(照片和
429
- * 渲染图转 png 会胖好几倍,本地盒子这条链路还要过 SSH 隧道)。**只在 allowed
430
- * 里挑**,上游声明什么就往什么转。
431
- *
432
- * gif 不在 IMAGE_WRITERS 的键里,所以动图永远不进这条路(重 encode 会丢帧)——
433
- * 代价是 gif 遇到解不开它的上游仍旧失败,那个另说,别在这儿悄悄压成一张静图。
434
- */
435
- async function maybeNormalizeImageBlock(block, maxDim, allowed) {
436
- const src = block?.source;
437
- if (!src || src.type !== 'base64' || !src.data) return null;
438
- if (!IMAGE_WRITERS[src.media_type]) return null;
439
-
440
- const needsTranscode = Array.isArray(allowed) && !allowed.includes(src.media_type);
441
-
442
- try {
443
- const inputBuf = Buffer.from(src.data, 'base64');
444
- const meta = await sharp(inputBuf).metadata();
445
- const w = meta.width || 0;
446
- const h = meta.height || 0;
447
- const needsResize = Math.max(w, h) > maxDim;
448
- if (!needsResize && !needsTranscode) return null;
449
-
450
- // 转码时目标格式从 allowed 里挑;不转码就还用原格式
451
- let outType = src.media_type;
452
- if (needsTranscode) {
453
- const prefer = meta.hasAlpha ? ['image/png', 'image/jpeg'] : ['image/jpeg', 'image/png'];
454
- outType = prefer.find((t) => allowed.includes(t) && IMAGE_WRITERS[t])
455
- || allowed.find((t) => IMAGE_WRITERS[t]);
456
- if (!outType) {
457
- console.warn(`[model-ingress] 上游 imageFormats 里没有能写的格式,原样透传 ${src.media_type}`);
458
- return null;
459
- }
460
- }
461
-
462
- let pipeline = sharp(inputBuf);
463
- if (needsResize) {
464
- pipeline = pipeline.resize({
465
- width: w >= h ? maxDim : null,
466
- height: h > w ? maxDim : null,
467
- fit: 'inside',
468
- withoutEnlargement: true,
469
- });
470
- }
471
- // 两种情况去 alpha:① 落到 jpeg(它没有 alpha 通道,不 flatten 出来是黑底);
472
- // ② 走了 resize —— 这条是原有行为,理由是"部分 vision 网关对 RGBA 不友好",
473
- // 别因为加转码顺手把它改掉(中转站那两个上游一直吃的是 flatten 过的图)。
474
- if (meta.hasAlpha && (needsResize || outType === 'image/jpeg')) {
475
- pipeline = pipeline.flatten({ background: '#ffffff' });
476
- }
477
- const outBuf = await IMAGE_WRITERS[outType](pipeline).toBuffer();
478
- return {
479
- ...block,
480
- source: { type: 'base64', media_type: outType, data: outBuf.toString('base64') },
481
- };
482
- } catch (err) {
483
- console.warn(`[model-ingress] image normalize failed (passthrough): ${err?.message || err}`);
484
- return null;
485
- }
486
- }
487
-
488
- /**
489
- * CJK 字符范围(token 估算分流):平/片假名 + CJK 基础汉字 + 韩文音节。
490
- * 系数:CJK 1 字 ≈ 1.3 token,英文 BPE ~4 char/token。
491
- */
492
- const CJK_REGEX = /[぀-鿿가-힯]/g;
493
-
494
- function estimateText(s) {
495
- if (typeof s !== 'string') return 0;
496
- const cjk = s.match(CJK_REGEX)?.length || 0;
497
- return cjk * 1.3 + (s.length - cjk) / 4;
498
- }
499
-
500
- /**
501
- * 粗估 messages body 的 input token 数(count_tokens 本地降级用)。
502
- * 实测误差:纯英文 ±10%,纯中文 ±15%,混合 ±20% —— 够 UI 进度条与预警分档。
503
- */
504
- export function estimateInputTokens(parsed) {
505
- try {
506
- let total = 0;
507
- const addText = (s) => { total += estimateText(s); };
508
- const addBlock = (b) => {
509
- if (!b) return;
510
- if (b.type === 'text' && b.text) addText(b.text);
511
- else if (b.type === 'image' && b.source?.data) total += b.source.data.length / 4;
512
- else if (b.type === 'tool_use') { addText(b.name || ''); addText(JSON.stringify(b.input || {})); }
513
- else if (b.type === 'tool_result') {
514
- if (typeof b.content === 'string') addText(b.content);
515
- else if (Array.isArray(b.content)) b.content.forEach(addBlock);
516
- } else if (b.type === 'thinking' && b.thinking) addText(b.thinking);
517
- };
518
- if (Array.isArray(parsed.messages)) {
519
- for (const msg of parsed.messages) {
520
- if (typeof msg.content === 'string') addText(msg.content);
521
- else if (Array.isArray(msg.content)) msg.content.forEach(addBlock);
522
- }
523
- }
524
- if (typeof parsed.system === 'string') addText(parsed.system);
525
- else if (Array.isArray(parsed.system)) parsed.system.forEach(addBlock);
526
- if (Array.isArray(parsed.tools)) {
527
- for (const t of parsed.tools) {
528
- addText(t.name || '');
529
- addText(t.description || '');
530
- addText(JSON.stringify(t.input_schema || {}));
531
- }
532
- }
533
- return Math.max(1, Math.round(total));
534
- } catch {
535
- return 50000; // 保守值 ≈ 1/5 上下文,误触 80% 警告概率低
536
- }
537
- }
538
-
539
- /** URL path 拼接(避免重复 / 或丢段)。moonshot base 自带 /anthropic 段靠它保住 */
540
- function joinPath(base, reqPath) {
541
- const cleanBase = (base || '').replace(/\/$/, '');
542
- return cleanBase + (reqPath.startsWith('/') ? reqPath : '/' + reqPath);
543
- }
1
+ /**
2
+ * server/lib/model-ingress.js — Anthropic 范式通用模型入口(2026-08-19,
3
+ * 前身 binary-fixup-proxy.js)。
4
+ *
5
+ * ## 定位
6
+ *
7
+ * SDK binary 永远说 Anthropic Messages 协议;所有非订阅模型都从这里进。
8
+ * 入口按 **请求 body.model 查表**(model-context.js 的 resolveWireModel)
9
+ * 决定发往哪个上游、换哪把钥匙、开哪些修补 —— 无会话状态、无进程级
10
+ * "当前模型" env(旧版的 NODESIGN_CURRENT_APP_MODEL 在多会话不同模型时
11
+ * 会互相覆盖,已随 NoDesk 包装一起退役)。
12
+ *
13
+ * ## 每请求流水线
14
+ *
15
+ * 1. 剥 /__nd/<sessionId> 路径前缀(只用于日志归属)
16
+ * 2. body.model 反查路由(appModel / sdkAlias / 剥[1m]的 alias 都认);
17
+ * 查不到 → 502 fail-loud,绝不盲转发(盲转发 = 烧错通路的钱且无人知晓)。
18
+ * 注册过的 API 会话只认自己那行 + 自己的 fast 行,其它名字改道 fast 兜底
19
+ * (未知名 = SDK helper;在表里但属于别的行 = 撞名雷,见 resolveSessionWire)
20
+ * 3. count_tokens:上游没有该端点(表里标了 / 404 实测过)→ 本地估算短路
21
+ * 4. 修补:model 还原成上游真名 · thinking strip/enabled8k · tool_result
22
+ * 图片提升到顶层(Kimi 与 Gemini 桥都丢 tool_result 图,08-19 探针实锤)·
23
+ * 超长边图片下采样
24
+ * 5. 换钥匙:上游钥匙按条目取(外部插槽 .key 内联 > env UPSTREAMS[].keyEnv),按 authStyle 发;
25
+ * binary 带来的 ANTHROPIC_API_KEY 是占位符,一律丢弃
26
+ * 6. 转发(SSE 直接 pipe,不解析响应流)
27
+ *
28
+ * ## 已知缺口(记录在案,别当没有)
29
+ *
30
+ * - 反重力通道流式 stop_reason 恒=end_turn 的病(tool_use 块在、收尾说错话)
31
+ * 需要解析改写 SSE 才能修 —— 那族通道只当玩具,不为它上复杂度。
32
+ * - prompt cache 不过桥(探针实测 cache 字段恒 0),长会话经济性靠模型价差硬扛。
33
+ */
34
+
35
+ import http from 'node:http';
36
+ import https from 'node:https';
37
+ import sharp from 'sharp';
38
+ import { resolveWireModel, UPSTREAMS } from '../engine/agent/model-context.js';
39
+ import { resolveSessionWire, fallbackLogged } from './ingress/session-routes.js';
40
+ import { forwardOpenAIChat } from './ingress/forward-openai-chat.js';
41
+ import { failStreaks, exhaustedErrorBody } from './ingress/upstream-fail-streak.js';
42
+ import { noteUpstreamBilling } from './ingress/upstream-billing.js';
43
+ import { noteUpstreamTruncation } from './ingress/upstream-truncation.js';
44
+ import { noticeSession } from './ingress/session-notice.js';
45
+
46
+ // ── 出站连接池 ──
47
+ // ⛔ 08-20 生产真踩:本地盒子(authStyle 'none' = 环回 SSH 隧道)走 Node 默认
48
+ // globalAgent,而它 **keepAlive 默认开着**。llama-server 几秒就关掉空闲连接,
49
+ // 那个 FIN 要先穿过 SSH 隧道才到 Node —— 窗口里 Node 会从池子里挑一条**其实已经
50
+ // 死了的 socket** 发请求,结果就是 `ECONNRESET: socket hang up` 刷屏(一分钟几百条),
51
+ // 而盒子那头 `ss` 看到的连接数是 0(两边对同一条连接的死活认知不一致,是识别信号)。
52
+ // 隧道内建连接本来就便宜(没有 TLS 握手),所以本地上游一律不复用连接。
53
+ // 远端上游(中转站/HTTPS)保持 keep-alive:那边握手贵,且没有隧道拖慢 FIN。
54
+ const AGENTS = new Map();
55
+ function agentFor(wire, useHttps) {
56
+ if (wire.upstream.authStyle !== 'none') return undefined; // 远端:用默认 globalAgent
57
+ const key = useHttps ? 'https' : 'http';
58
+ if (!AGENTS.has(key)) AGENTS.set(key, new (useHttps ? https : http).Agent({ keepAlive: false }));
59
+ return AGENTS.get(key);
60
+ }
61
+
62
+ let _instance = null;
63
+
64
+ const PREFIX_RE = /^\/__nd\/([^/]+)(\/.*)$/;
65
+
66
+ // Vision 下采样阈值(长边像素)。1568 = Anthropic token 优化阈值,也是多数
67
+ // 中转网关安全线(mili-logo 2500×2500 曾触发网关 400)。
68
+ const VISION_MAX_DIM = (() => {
69
+ const n = Number(process.env.NODESIGN_VISION_MAX_DIM);
70
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1568;
71
+ })();
72
+
73
+ // count_tokens capability 运行时缓存:upstreamId → false(404 实测确认没有)。
74
+ // 表里 countTokens:false 的上游直接短路,true 的先转发、404 后降级并记住。
75
+ const countTokensDead = new Set();
76
+
77
+ // 会话级路由(注册 / 注销 / 决策)住 ingress/session-routes.js;这里转出口给 session-loop 与测试
78
+ export { registerIngressSession, unregisterIngressSession, resolveSessionWire } from './ingress/session-routes.js';
79
+
80
+ /**
81
+ * 启动入口(幂等单例;路由是 per-request 的,单例不再锁死目标)。
82
+ * @returns {Promise<{ baseUrl: string, close: () => Promise<void> }>}
83
+ */
84
+ export async function getOrStartIngress() {
85
+ if (_instance) return _instance;
86
+
87
+ const server = http.createServer((req, res) => {
88
+ const chunks = [];
89
+ req.on('data', (c) => chunks.push(c));
90
+ req.on('end', async () => {
91
+ try {
92
+ await handleRequest(req, res, Buffer.concat(chunks));
93
+ } catch (err) {
94
+ console.error(`[model-ingress] handler error: ${err?.stack || err?.message || err}`);
95
+ try { res.writeHead(502); res.end(`ingress handler error: ${err?.message || 'unknown'}`); } catch { /* ignore */ }
96
+ }
97
+ });
98
+ req.on('error', (err) => {
99
+ console.error(`[model-ingress] request error: ${err.message}`);
100
+ try { res.writeHead(400); res.end(); } catch { /* ignore */ }
101
+ });
102
+ });
103
+
104
+ await new Promise((resolve, reject) => {
105
+ const onError = (err) => { server.removeListener('listening', onListening); reject(err); };
106
+ const onListening = () => { server.removeListener('error', onError); resolve(); };
107
+ server.once('error', onError);
108
+ server.once('listening', onListening);
109
+ server.listen(0, '127.0.0.1');
110
+ });
111
+
112
+ const baseUrl = `http://127.0.0.1:${server.address().port}`;
113
+ console.log(`[model-ingress] listening on ${baseUrl} (upstreams: ${Object.keys(UPSTREAMS).join(', ')})`);
114
+ _instance = { baseUrl, close: () => new Promise((r) => server.close(() => r())), server };
115
+ return _instance;
116
+ }
117
+
118
+ export async function stopIngress() {
119
+ if (!_instance) return;
120
+ await _instance.close();
121
+ _instance = null;
122
+ }
123
+
124
+ async function handleRequest(req, res, bodyBuf) {
125
+ // 剥 /__nd/<sessionId> 前缀(日志归属用)
126
+ let sessionTag = null;
127
+ let origPath = req.url;
128
+ const m = PREFIX_RE.exec(req.url);
129
+ if (m) { sessionTag = decodeURIComponent(m[1]); origPath = m[2]; }
130
+ const sidShort = sessionTag ? sessionTag.slice(0, 8) : '-';
131
+
132
+ if (!(req.method === 'POST' && /^\/v1\/messages\b/.test(origPath))) {
133
+ res.writeHead(502, { 'Content-Type': 'text/plain' });
134
+ res.end(`model-ingress: unsupported ${req.method} ${origPath}`);
135
+ return;
136
+ }
137
+
138
+ let parsed;
139
+ try {
140
+ parsed = JSON.parse(bodyBuf.toString('utf8'));
141
+ } catch {
142
+ // 没有 model 就没法路由 —— fail-loud 而不是盲转发
143
+ res.writeHead(502, { 'Content-Type': 'text/plain' });
144
+ res.end('model-ingress: body is not JSON, cannot route');
145
+ return;
146
+ }
147
+
148
+ const routed = resolveSessionWire(parsed?.model, sessionTag);
149
+ const wire = routed.wire;
150
+ if (wire && routed.reason !== 'table') {
151
+ const logKey = `${sessionTag}:${parsed?.model}`;
152
+ if (!fallbackLogged.has(logKey)) {
153
+ fallbackLogged.add(logKey);
154
+ if (routed.reason === 'collision') {
155
+ console.warn(`[model-ingress] sid=${sidShort} ⛔ 撞名:model '${parsed?.model}' 属于别的行(${routed.collidesWith}),本会话是 ${routed.sessionModel} —— 不跨行转发,改道会话 fast(${routed.fastModel})`);
156
+ } else {
157
+ console.warn(`[model-ingress] sid=${sidShort} 未知 model '${parsed?.model}' → 会话 fast 兜底(${routed.fastModel})`);
158
+ }
159
+ }
160
+ }
161
+ if (!wire) {
162
+ res.writeHead(502, { 'Content-Type': 'text/plain' });
163
+ res.end(`model-ingress: no route for model '${parsed?.model}' — 该名字不在 model-context.js 的 API 表里`);
164
+ console.warn(`[model-ingress] sid=${sidShort} 未知 model '${parsed?.model}',已拒绝(fail-loud)`);
165
+ return;
166
+ }
167
+
168
+ // authStyle 'none' = 无鉴权上游(本地 llama-server 走环回隧道),不需要钥匙
169
+ const needKey = wire.upstream.authStyle !== 'none';
170
+ // 钥匙取用点(全入口唯一一处):外部插槽(runtime/local-config.js)把钥匙写在条目里;内置行走 env
171
+ const key = needKey ? (wire.upstream.key || (wire.upstream.keyEnv ? process.env[wire.upstream.keyEnv] : null)) : null;
172
+ if (needKey && !key) {
173
+ res.writeHead(502, { 'Content-Type': 'text/plain' });
174
+ res.end(`model-ingress: 上游钥匙未配置(${wire.upstream.keyEnv ? `env ${wire.upstream.keyEnv} 为空` : `上游 ${wire.upstreamId} 没填 key`})`);
175
+ return;
176
+ }
177
+
178
+ const isCountTokens = /^\/v1\/messages\/count_tokens\b/.test(origPath);
179
+ // CLI 在**回合之间**也用主模型打请求(猜你想问 / 标题生成),ingress 分不出角色(都是 main)。
180
+ // 它们的半截不该触发续接:那是给用户的建议词,不是 agent 的回答,接下去也没意义,
181
+ // 而且标记会串到下一个回合头上(fable 评审 P2;真路径探针里 SUGGESTION MODE 每回合都来一发)。
182
+ // 认的是 CLI 自己写死的提示词开头,认不出来最多是少一道保险(还有回合内新鲜度判据兜底)。
183
+ const isSuggestionQuery = (() => {
184
+ try {
185
+ const last = parsed?.messages?.[parsed.messages.length - 1];
186
+ const txt = typeof last?.content === 'string' ? last.content : JSON.stringify(last?.content || '');
187
+ return txt.includes('SUGGESTION MODE');
188
+ } catch { return false; }
189
+ })();
190
+
191
+ // 会话连续失败上限(upstream-fail-streak.js,僵尸 run 案):上游持续死时 CLI 对 5xx 无上限退避重试,
192
+ // 一个回合能挂一小时。到上限就回 400(CLI 不重试、回合以 is_error 收场、文案到用户、会话不死),
193
+ // 计数归零让用户下次再发有新机会。count_tokens 不参与(它不是循环的燃料)。
194
+ // 计数按 sid+角色分桶:helper(标题/分类器)一次成功不能把主行攒的计数清零(08-21 评审抓的洞)
195
+ const streakKey = sessionTag ? `${sessionTag}:${routed.role || 'main'}` : null;
196
+ if (!isCountTokens && failStreaks.exhausted(streakKey)) {
197
+ const { n, reason } = failStreaks.consume(streakKey);
198
+ const body = JSON.stringify(exhaustedErrorBody({ label: wire.upstream?.label || wire.upstreamId, n, reason }));
199
+ console.warn(`[model-ingress] sid=${sidShort} role=${routed.role} upstream=${wire.upstreamId} 连续失败 ${n} 次(${reason})→ 400 止损,计数归零`);
200
+ res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': String(Buffer.byteLength(body)) });
201
+ res.end(body);
202
+ return;
203
+ }
204
+ const noteOutcome = (ok, reason) => {
205
+ if (isCountTokens) return;
206
+ const n = failStreaks.note(streakKey, ok, reason);
207
+ // 上游抖的时候会话里什么都不动(CLI 在退避重试,一次 503 能挂 50~140 秒)—— 推一句人话,
208
+ // 让用户知道是在重试而不是卡死,等不及可以自己点停止。节流住在 session-notice.js。
209
+ if (!ok && routed.role !== 'helper') {
210
+ const label = wire.upstream?.label || wire.upstreamId;
211
+ noticeSession(sessionTag, {
212
+ key: 'upstream_retry',
213
+ text: `${label} 上游繁忙(${reason || '未知'}),正在自动重试第 ${n} 次;等不及可以点停止。`,
214
+ priority: 'warn',
215
+ });
216
+ }
217
+ };
218
+
219
+ // count_tokens:上游没有该端点 → 本地估算短路(SDK 内部窗口计数要有数,
220
+ // 否则 ContextUsageBar 永远"等待"、80% 预警从不触发 —— DMXAPI 时代真踩过)
221
+ const upstreamHasCount = wire.upstream.countTokens !== false && !countTokensDead.has(wire.upstreamId);
222
+ if (isCountTokens && !upstreamHasCount) {
223
+ const respBody = JSON.stringify({ input_tokens: estimateInputTokens(parsed) });
224
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': String(Buffer.byteLength(respBody)) });
225
+ res.end(respBody);
226
+ return;
227
+ }
228
+
229
+ // 修补流水线(count_tokens 也要 model 还原,其余修补无害)
230
+ await transformForUpstream(parsed, wire);
231
+
232
+ // 协议分岔:上游说 OpenAI chat(Zen 等)→ 转换层;其余透传 Anthropic
233
+ if (wire.protocol === 'openai-chat') {
234
+ const target = new URL(wire.upstream.baseUrl);
235
+ // helper 请求降档:主行想多少归主行,helper 一句话的活用 helperReasoningEffort(默认 low)
236
+ const wireFwd = routed.role === 'helper' && wire.helperReasoningEffort ? { ...wire, reasoningEffort: wire.helperReasoningEffort } : wire;
237
+ forwardOpenAIChat({ parsed, wire: wireFwd, key, res, sidShort, target, path: joinPath(target.pathname, '/chat/completions'), agent: agentFor(wire, target.protocol === 'https:'), onOutcome: noteOutcome,
238
+ // 上游自报费用按会话 × appModel 累加(helper 请求记到 helper 行头上),session-loop 结账时取走
239
+ onBilling: (info) => noteUpstreamBilling(sessionTag, wireFwd.appModel, info),
240
+ // 「说到一半被掐」标记:session-loop 收到 result 时取走,有标记就自动续接一轮(upstream-truncation.js)。
241
+ // ⚠️ helper 一律不报(连"收得完整"也不报):它就一句话的活不值得续接,而报 null 会把主行刚记下的
242
+ // 标记清掉(标题/摘要那发常常紧跟在主回合后面)。
243
+ onTruncated: (reason) => {
244
+ if (routed.role === 'helper' || isCountTokens || isSuggestionQuery) return;
245
+ noteUpstreamTruncation(sessionTag, reason, { appModel: wireFwd.appModel });
246
+ },
247
+ // 就地重发时说一声:那期间会话里什么都不动,用户只看到一个转不停的绿点(跟"上游繁忙"同一条通道)
248
+ onNotice: (text) => {
249
+ if (routed.role === 'helper') return; // helper 的重发用户不需要知道
250
+ noticeSession(sessionTag, { key: 'upstream_retry', text, priority: 'warn' });
251
+ } });
252
+ return;
253
+ }
254
+ const outBody = Buffer.from(JSON.stringify(parsed), 'utf8');
255
+
256
+ // 换钥匙 + 头处理
257
+ const target = new URL(wire.upstream.baseUrl);
258
+ const useHttps = target.protocol === 'https:';
259
+ const headers = { ...req.headers, host: target.hostname };
260
+ delete headers['x-api-key'];
261
+ delete headers['authorization'];
262
+ if (wire.upstream.authStyle === 'bearer') headers['authorization'] = `Bearer ${key}`;
263
+ else if (needKey) headers['x-api-key'] = key;
264
+ headers['content-length'] = String(outBody.length);
265
+
266
+ const proxyReq = (useHttps ? https : http).request({
267
+ hostname: target.hostname,
268
+ port: target.port || (useHttps ? 443 : 80),
269
+ path: joinPath(target.pathname, origPath),
270
+ method: 'POST',
271
+ headers,
272
+ agent: agentFor(wire, useHttps),
273
+ }, (proxyRes) => {
274
+ // count_tokens 404 → 降级本地估算并记住这个上游没有该端点
275
+ if (isCountTokens && (proxyRes.statusCode === 404 || proxyRes.statusCode === 405)) {
276
+ countTokensDead.add(wire.upstreamId);
277
+ proxyRes.resume(); // 丢弃上游响应体
278
+ const respBody = JSON.stringify({ input_tokens: estimateInputTokens(parsed) });
279
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': String(Buffer.byteLength(respBody)) });
280
+ res.end(respBody);
281
+ console.warn(`[model-ingress] ${wire.upstreamId} 没有 count_tokens(404),已降级本地估算并缓存`);
282
+ return;
283
+ }
284
+ // 上游 4xx/5xx:console.warn 一行 status + model + body 前 200 字,PM2 日志直接看到根因
285
+ if (proxyRes.statusCode >= 400) {
286
+ const respChunks = [];
287
+ proxyRes.on('data', (c) => respChunks.push(c));
288
+ proxyRes.on('end', () => {
289
+ const preview = Buffer.concat(respChunks).slice(0, 200).toString('utf8').replace(/\s+/g, ' ');
290
+ console.warn(`[model-ingress] sid=${sidShort} upstream=${wire.upstreamId} ${proxyRes.statusCode} model=${wire.wireModel} body=${preview}`);
291
+ });
292
+ // 透传路:5xx 记一次失败(4xx 是请求本身的问题,CLI 不重试,不记);2xx 算成功
293
+ if (proxyRes.statusCode >= 500) noteOutcome(false, `HTTP ${proxyRes.statusCode}`);
294
+ } else {
295
+ noteOutcome(true);
296
+ }
297
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
298
+ proxyRes.pipe(res);
299
+ });
300
+
301
+ proxyReq.on('error', (err) => {
302
+ const detail = err.code ? `${err.code}: ${err.message}` : err.message;
303
+ console.error(`[model-ingress] forward error (${wire.upstreamId}): ${detail}`);
304
+ noteOutcome(false, `forward: ${detail}`);
305
+ try { res.writeHead(502); res.end(`ingress forward error: ${detail}`); } catch { /* ignore */ }
306
+ });
307
+
308
+ proxyReq.write(outBody);
309
+ proxyReq.end();
310
+ }
311
+
312
+ /**
313
+ * 按路由行修补请求 body(原地改)。导出供单测直接喂假 body 验证。
314
+ *
315
+ * @param {object} parsed Anthropic Messages body
316
+ * @param {object} wire resolveWireModel 的返回
317
+ * @returns {Promise<boolean>} 是否有改动
318
+ */
319
+ export async function transformForUpstream(parsed, wire) {
320
+ let mutated = false;
321
+
322
+ // model 还原成上游真名(SDK 发的是 spoof alias 或 appModel)
323
+ if (parsed.model !== wire.wireModel) {
324
+ parsed.model = wire.wireModel;
325
+ mutated = true;
326
+ }
327
+
328
+ // thinking:'strip' = 删字段让上游自决(Gemini thinking 关不掉、参数过桥行为
329
+ // 未知);'enabled8k' = adaptive 改写成 enabled+budget(Kimi 实测 adaptive =
330
+ // 0 thinking blocks)
331
+ if (wire.thinking === 'strip') {
332
+ if ('thinking' in parsed) { delete parsed.thinking; mutated = true; }
333
+ } else if (wire.thinking === 'enabled8k') {
334
+ if (parsed.thinking?.type === 'adaptive') {
335
+ parsed.thinking = { type: 'enabled', budget_tokens: 8192 };
336
+ mutated = true;
337
+ }
338
+ }
339
+
340
+ // tool_result 里的图提升到 user message 顶层(丢图桥的必修 shim)
341
+ if (wire.liftImages && Array.isArray(parsed.messages)) {
342
+ if (liftImagesFromToolResult(parsed.messages)) mutated = true;
343
+ }
344
+
345
+ // 图片归一:长边超限下采样(所有上游统一做,安全无害)+ 把上游解不开的格式
346
+ // 转码过去(只有声明了 imageFormats 的上游才做,见 model-context 那张表)
347
+ if (Array.isArray(parsed.messages)) {
348
+ const allowed = wire.upstream?.imageFormats || null;
349
+ if (await normalizeImages(parsed.messages, VISION_MAX_DIM, allowed)) mutated = true;
350
+ }
351
+
352
+ return mutated;
353
+ }
354
+
355
+ /**
356
+ * 把 tool_result.content 里的 image block 提到外层 user message content 顶层;
357
+ * 原位置替换为占位文本。Kimi S8 时代写的,08-19 探针证明 Gemini 桥同样需要。
358
+ */
359
+ export function liftImagesFromToolResult(messages) {
360
+ let mutated = false;
361
+ for (const msg of messages) {
362
+ if (msg?.role !== 'user' || !Array.isArray(msg.content)) continue;
363
+ const liftedImages = [];
364
+ for (const block of msg.content) {
365
+ if (block?.type !== 'tool_result' || !Array.isArray(block.content)) continue;
366
+ block.content = block.content.map((inner) => {
367
+ if (inner?.type === 'image' && inner.source?.data) {
368
+ liftedImages.push({ ...inner });
369
+ mutated = true;
370
+ return {
371
+ type: 'text',
372
+ text: '[image lifted to end of this message for upstream vision compat — see image block below]',
373
+ };
374
+ }
375
+ return inner;
376
+ });
377
+ }
378
+ if (liftedImages.length > 0) msg.content.push(...liftedImages);
379
+ }
380
+ return mutated;
381
+ }
382
+
383
+ /**
384
+ * 图片归一:扫所有 image block(user msg 顶层 + tool_result 内嵌),按需下采样
385
+ * 和/或转码。两件事共用一次遍历也共用一条 sharp 管线 —— 拆成两个函数就是把这段
386
+ * 嵌套遍历抄第二份,而它已经是"顶层 + tool_result 内嵌"两层的形状了。
387
+ *
388
+ * fail-soft:单张图 sharp 抛错 → warn 后保留原图透传,不阻断整 turn。
389
+ *
390
+ * @param {number} maxDim 长边上限,超了才 resize
391
+ * @param {string[]|null} allowed 上游解得开的 media_type 白名单;null = 不限
392
+ */
393
+ async function normalizeImages(messages, maxDim, allowed) {
394
+ let mutated = false;
395
+ for (const msg of messages) {
396
+ if (!Array.isArray(msg?.content)) continue;
397
+ for (let i = 0; i < msg.content.length; i++) {
398
+ const block = msg.content[i];
399
+ if (block?.type === 'image') {
400
+ const replaced = await maybeNormalizeImageBlock(block, maxDim, allowed);
401
+ if (replaced) { msg.content[i] = replaced; mutated = true; }
402
+ }
403
+ if (block?.type === 'tool_result' && Array.isArray(block.content)) {
404
+ for (let j = 0; j < block.content.length; j++) {
405
+ const inner = block.content[j];
406
+ if (inner?.type === 'image') {
407
+ const replaced = await maybeNormalizeImageBlock(inner, maxDim, allowed);
408
+ if (replaced) { block.content[j] = replaced; mutated = true; }
409
+ }
410
+ }
411
+ }
412
+ }
413
+ }
414
+ return mutated;
415
+ }
416
+
417
+ const IMAGE_WRITERS = {
418
+ 'image/png': (p) => p.png({ compressionLevel: 9 }),
419
+ 'image/jpeg': (p) => p.jpeg({ quality: 90, mozjpeg: true }),
420
+ 'image/webp': (p) => p.webp({ quality: 85 }),
421
+ };
422
+
423
+ /**
424
+ * 单图归一。两个触发条件,满足任一才动(都不满足返 null 原样透传):
425
+ * - 长边超过 maxDim → 下采样
426
+ * - media_type 不在上游的 imageFormats 里 → 转码
427
+ *
428
+ * 转码目标按有没有 alpha 分:有 alpha 走 png(保住透明),没有走 jpeg(照片和
429
+ * 渲染图转 png 会胖好几倍,本地盒子这条链路还要过 SSH 隧道)。**只在 allowed
430
+ * 里挑**,上游声明什么就往什么转。
431
+ *
432
+ * gif 不在 IMAGE_WRITERS 的键里,所以动图永远不进这条路(重 encode 会丢帧)——
433
+ * 代价是 gif 遇到解不开它的上游仍旧失败,那个另说,别在这儿悄悄压成一张静图。
434
+ */
435
+ async function maybeNormalizeImageBlock(block, maxDim, allowed) {
436
+ const src = block?.source;
437
+ if (!src || src.type !== 'base64' || !src.data) return null;
438
+ if (!IMAGE_WRITERS[src.media_type]) return null;
439
+
440
+ const needsTranscode = Array.isArray(allowed) && !allowed.includes(src.media_type);
441
+
442
+ try {
443
+ const inputBuf = Buffer.from(src.data, 'base64');
444
+ const meta = await sharp(inputBuf).metadata();
445
+ const w = meta.width || 0;
446
+ const h = meta.height || 0;
447
+ const needsResize = Math.max(w, h) > maxDim;
448
+ if (!needsResize && !needsTranscode) return null;
449
+
450
+ // 转码时目标格式从 allowed 里挑;不转码就还用原格式
451
+ let outType = src.media_type;
452
+ if (needsTranscode) {
453
+ const prefer = meta.hasAlpha ? ['image/png', 'image/jpeg'] : ['image/jpeg', 'image/png'];
454
+ outType = prefer.find((t) => allowed.includes(t) && IMAGE_WRITERS[t])
455
+ || allowed.find((t) => IMAGE_WRITERS[t]);
456
+ if (!outType) {
457
+ console.warn(`[model-ingress] 上游 imageFormats 里没有能写的格式,原样透传 ${src.media_type}`);
458
+ return null;
459
+ }
460
+ }
461
+
462
+ let pipeline = sharp(inputBuf);
463
+ if (needsResize) {
464
+ pipeline = pipeline.resize({
465
+ width: w >= h ? maxDim : null,
466
+ height: h > w ? maxDim : null,
467
+ fit: 'inside',
468
+ withoutEnlargement: true,
469
+ });
470
+ }
471
+ // 两种情况去 alpha:① 落到 jpeg(它没有 alpha 通道,不 flatten 出来是黑底);
472
+ // ② 走了 resize —— 这条是原有行为,理由是"部分 vision 网关对 RGBA 不友好",
473
+ // 别因为加转码顺手把它改掉(中转站那两个上游一直吃的是 flatten 过的图)。
474
+ if (meta.hasAlpha && (needsResize || outType === 'image/jpeg')) {
475
+ pipeline = pipeline.flatten({ background: '#ffffff' });
476
+ }
477
+ const outBuf = await IMAGE_WRITERS[outType](pipeline).toBuffer();
478
+ return {
479
+ ...block,
480
+ source: { type: 'base64', media_type: outType, data: outBuf.toString('base64') },
481
+ };
482
+ } catch (err) {
483
+ console.warn(`[model-ingress] image normalize failed (passthrough): ${err?.message || err}`);
484
+ return null;
485
+ }
486
+ }
487
+
488
+ /**
489
+ * CJK 字符范围(token 估算分流):平/片假名 + CJK 基础汉字 + 韩文音节。
490
+ * 系数:CJK 1 字 ≈ 1.3 token,英文 BPE ~4 char/token。
491
+ */
492
+ const CJK_REGEX = /[぀-鿿가-힯]/g;
493
+
494
+ function estimateText(s) {
495
+ if (typeof s !== 'string') return 0;
496
+ const cjk = s.match(CJK_REGEX)?.length || 0;
497
+ return cjk * 1.3 + (s.length - cjk) / 4;
498
+ }
499
+
500
+ /**
501
+ * 粗估 messages body 的 input token 数(count_tokens 本地降级用)。
502
+ * 实测误差:纯英文 ±10%,纯中文 ±15%,混合 ±20% —— 够 UI 进度条与预警分档。
503
+ */
504
+ export function estimateInputTokens(parsed) {
505
+ try {
506
+ let total = 0;
507
+ const addText = (s) => { total += estimateText(s); };
508
+ const addBlock = (b) => {
509
+ if (!b) return;
510
+ if (b.type === 'text' && b.text) addText(b.text);
511
+ else if (b.type === 'image' && b.source?.data) total += b.source.data.length / 4;
512
+ else if (b.type === 'tool_use') { addText(b.name || ''); addText(JSON.stringify(b.input || {})); }
513
+ else if (b.type === 'tool_result') {
514
+ if (typeof b.content === 'string') addText(b.content);
515
+ else if (Array.isArray(b.content)) b.content.forEach(addBlock);
516
+ } else if (b.type === 'thinking' && b.thinking) addText(b.thinking);
517
+ };
518
+ if (Array.isArray(parsed.messages)) {
519
+ for (const msg of parsed.messages) {
520
+ if (typeof msg.content === 'string') addText(msg.content);
521
+ else if (Array.isArray(msg.content)) msg.content.forEach(addBlock);
522
+ }
523
+ }
524
+ if (typeof parsed.system === 'string') addText(parsed.system);
525
+ else if (Array.isArray(parsed.system)) parsed.system.forEach(addBlock);
526
+ if (Array.isArray(parsed.tools)) {
527
+ for (const t of parsed.tools) {
528
+ addText(t.name || '');
529
+ addText(t.description || '');
530
+ addText(JSON.stringify(t.input_schema || {}));
531
+ }
532
+ }
533
+ return Math.max(1, Math.round(total));
534
+ } catch {
535
+ return 50000; // 保守值 ≈ 1/5 上下文,误触 80% 警告概率低
536
+ }
537
+ }
538
+
539
+ /** URL path 拼接(避免重复 / 或丢段)。moonshot base 自带 /anthropic 段靠它保住 */
540
+ function joinPath(base, reqPath) {
541
+ const cleanBase = (base || '').replace(/\/$/, '');
542
+ return cleanBase + (reqPath.startsWith('/') ? reqPath : '/' + reqPath);
543
+ }