@xiaobuyu/nodesign 0.0.1 → 0.0.2

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 (282) 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 +22 -22
  9. package/server/api/assets/notes.js +144 -144
  10. package/server/api/assets.js +896 -896
  11. package/server/api/board.js +57 -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 +464 -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 +215 -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 +336 -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 +1024 -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 +339 -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 +155 -155
  118. package/server/engine/mcp/tools/crystallize-skill.js +168 -168
  119. package/server/engine/mcp/tools/explain-style.js +237 -237
  120. package/server/engine/mcp/tools/export-handoff.js +133 -133
  121. package/server/engine/mcp/tools/expose-tweaks.js +149 -149
  122. package/server/engine/mcp/tools/generate-image.js +842 -842
  123. package/server/engine/mcp/tools/get-computed-styles.js +164 -164
  124. package/server/engine/mcp/tools/get-pending-changes.js +205 -205
  125. package/server/engine/mcp/tools/h3box-ssh.js +84 -84
  126. package/server/engine/mcp/tools/helpers/acquire-page.js +82 -82
  127. package/server/engine/mcp/tools/helpers/motion-lab.js +554 -554
  128. package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -112
  129. package/server/engine/mcp/tools/helpers/perception-page.js +215 -215
  130. package/server/engine/mcp/tools/helpers/reference-download.js +103 -103
  131. package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -80
  132. package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -280
  133. package/server/engine/mcp/tools/highlight.js +68 -68
  134. package/server/engine/mcp/tools/list-pages.js +222 -222
  135. package/server/engine/mcp/tools/lookup-tags.js +59 -59
  136. package/server/engine/mcp/tools/navigate-to-page.js +60 -60
  137. package/server/engine/mcp/tools/organize-board.js +64 -64
  138. package/server/engine/mcp/tools/paint-still.js +434 -434
  139. package/server/engine/mcp/tools/pin-to-board.js +130 -130
  140. package/server/engine/mcp/tools/preview-deck.js +130 -130
  141. package/server/engine/mcp/tools/profile-scroll.js +305 -305
  142. package/server/engine/mcp/tools/publish-site.js +102 -102
  143. package/server/engine/mcp/tools/query-elements.js +195 -195
  144. package/server/engine/mcp/tools/read-board.js +103 -103
  145. package/server/engine/mcp/tools/read-document.js +93 -93
  146. package/server/engine/mcp/tools/read-page.js +264 -264
  147. package/server/engine/mcp/tools/read-tavern-json.js +143 -143
  148. package/server/engine/mcp/tools/record-decision.js +140 -140
  149. package/server/engine/mcp/tools/relate-on-board.js +110 -110
  150. package/server/engine/mcp/tools/report-issue.js +115 -115
  151. package/server/engine/mcp/tools/roll-film.js +266 -266
  152. package/server/engine/mcp/tools/screenshot-docx.js +138 -138
  153. package/server/engine/mcp/tools/screenshot-url.js +195 -195
  154. package/server/engine/mcp/tools/screenshot.js +583 -583
  155. package/server/engine/mcp/tools/tier-gate.js +96 -96
  156. package/server/engine/mcp/tools/trace-motion.js +215 -215
  157. package/server/engine/mcp/tools/web-search.js +548 -548
  158. package/server/engine/motion/inventory.js +349 -349
  159. package/server/engine/perception/session.js +235 -235
  160. package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -5
  161. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -192
  162. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -667
  163. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -22
  164. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -25
  165. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -23
  166. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -21
  167. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -23
  168. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -21
  169. package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -162
  170. package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -407
  171. package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -80
  172. package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -228
  173. 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
  174. package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -268
  175. package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -323
  176. package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -134
  177. package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -61
  178. package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -163
  179. package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -86
  180. package/server/engine/runs/active-runs.js +698 -698
  181. package/server/engine/runs/live-turn.js +252 -252
  182. package/server/engine/runs/store.js +364 -364
  183. package/server/engine/runs/turn-relay.js +217 -217
  184. package/server/engine/runtime/workspace.js +164 -164
  185. package/server/lib/artifact-file-path.js +80 -80
  186. package/server/lib/artifact-target.js +284 -284
  187. package/server/lib/asset-refs.js +239 -239
  188. package/server/lib/async-queue.js +104 -104
  189. package/server/lib/auto-relations.js +130 -130
  190. package/server/lib/binding-types.js +60 -60
  191. package/server/lib/board-kind-sizes.js +62 -62
  192. package/server/lib/board-relations.js +133 -133
  193. package/server/lib/browse-proxy.js +180 -180
  194. package/server/lib/canvas-id.js +36 -36
  195. package/server/lib/cover.js +227 -227
  196. package/server/lib/danbooru-tags.js +291 -291
  197. package/server/lib/doc-extract.js +156 -156
  198. package/server/lib/docx/build-from-source.js +260 -260
  199. package/server/lib/docx/build.js +490 -490
  200. package/server/lib/docx/dump-styles.js +287 -287
  201. package/server/lib/docx/fonts/nodesign-cjk.conf +164 -164
  202. package/server/lib/docx/merge-runs.js +129 -129
  203. package/server/lib/docx/numbering.js +218 -218
  204. package/server/lib/docx/order.js +152 -152
  205. package/server/lib/docx/rawzip.js +172 -172
  206. package/server/lib/docx/render.js +92 -92
  207. package/server/lib/docx/text-lint.js +225 -225
  208. package/server/lib/docx/tokens.js +335 -335
  209. package/server/lib/docx/units.js +29 -29
  210. package/server/lib/docx/xml.js +291 -291
  211. package/server/lib/docx-pages.js +187 -187
  212. package/server/lib/export-collect.js +289 -289
  213. package/server/lib/export-package.js +210 -210
  214. package/server/lib/html-srcset.js +145 -145
  215. package/server/lib/image-variant.js +460 -460
  216. package/server/lib/ingress/forward-openai-chat.js +305 -305
  217. package/server/lib/ingress/openai-chat.js +491 -491
  218. package/server/lib/ingress/session-notice.js +66 -66
  219. package/server/lib/ingress/session-routes.js +68 -68
  220. package/server/lib/ingress/slot-probe.js +130 -130
  221. package/server/lib/ingress/upstream-billing.js +52 -52
  222. package/server/lib/ingress/upstream-fail-streak.js +72 -72
  223. package/server/lib/ingress/upstream-truncation.js +48 -48
  224. package/server/lib/issues-store.js +182 -182
  225. package/server/lib/kinds/deck.js +72 -72
  226. package/server/lib/kinds/docx.js +283 -283
  227. package/server/lib/kinds/file-kinds.js +113 -113
  228. package/server/lib/kinds/index.js +202 -202
  229. package/server/lib/kinds/site.js +161 -161
  230. package/server/lib/model-ingress.js +543 -543
  231. package/server/lib/moderation.js +255 -255
  232. package/server/lib/notice-store.js +103 -103
  233. package/server/lib/plugin-install.js +104 -104
  234. package/server/lib/plugin-validator.js +721 -721
  235. package/server/lib/publish-store.js +115 -115
  236. package/server/lib/quick-summary.js +52 -52
  237. package/server/lib/quota.js +270 -270
  238. package/server/lib/rate-window.js +29 -29
  239. package/server/lib/reference-assets.js +92 -92
  240. package/server/lib/region-shot.js +135 -135
  241. package/server/lib/safe-path.js +73 -73
  242. package/server/lib/sdk-session.js +33 -33
  243. package/server/lib/showcase-store.js +92 -92
  244. package/server/lib/site-publish.js +465 -465
  245. package/server/lib/ssrf-guard.js +432 -432
  246. package/server/lib/task-scan.js +158 -158
  247. package/server/lib/tavern-json.js +154 -154
  248. package/server/lib/video-variant.js +237 -237
  249. package/server/lib/workspace-path.js +33 -33
  250. package/server/ops/fix-sdk-musl.mjs +40 -40
  251. package/server/ops/install-macos-fonts.sh +114 -114
  252. package/server/ops/macos-fonts.conf +336 -336
  253. package/server/ops/sandbox-shim/bwrap +57 -57
  254. package/server/projects/assets-summary.js +119 -119
  255. package/server/projects/auto-name.js +53 -53
  256. package/server/projects/board-store.js +640 -640
  257. package/server/projects/move-entry.js +103 -103
  258. package/server/projects/store.js +270 -270
  259. package/server/projects/ui-config.js +54 -54
  260. package/server/projects/workspace-templates.js +53 -53
  261. package/server/projects/workspace.js +1025 -1025
  262. package/server/runtime/local-config.js +177 -177
  263. package/server/runtime/profile.js +72 -72
  264. package/server/services/rembg-service.py +334 -334
  265. package/server/shared/README.md +25 -25
  266. package/server/shared/deck.js +54 -54
  267. package/server/shared/time.js +49 -49
  268. package/server/style-pipeline/README.md +53 -53
  269. package/server/ws/broker.js +36 -36
  270. package/server/ws/browse-channel.js +177 -177
  271. package/server/ws/index.js +386 -386
  272. package/web/README.md +89 -89
  273. package/web/dist/assets/BrowserWindow-BYCgizHw.js +11 -0
  274. package/web/dist/assets/{DeckWindow-4G0xX7P3.js → DeckWindow-pNydEM4L.js} +1 -1
  275. package/web/dist/assets/{DocxWindow-BXQsoPkh.js → DocxWindow-PPgzh3Gq.js} +1 -1
  276. package/web/dist/assets/{SiteWindow-D6eS-2gM.js → SiteWindow-DM0LQPCO.js} +1 -1
  277. package/web/dist/assets/{index-Cbl3ATvp.js → index-BHTBJlpY.js} +32 -32
  278. package/web/dist/assets/{pending-edit-apply-DTjo2geW.js → pending-edit-apply-C0I13CbD.js} +1 -1
  279. package/web/dist/assets/{rotate-cw-LMT3x1Zj.js → rotate-cw-B1UQ8Hld.js} +1 -1
  280. package/web/dist/assets/{square-dashed-mouse-pointer-CBoAkfhi.js → square-dashed-mouse-pointer-8lrgqmfv.js} +1 -1
  281. package/web/dist/index.html +17 -17
  282. package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +0 -11
@@ -1,491 +1,491 @@
1
- /**
2
- * lib/ingress/openai-chat.js — Anthropic Messages ⇄ OpenAI Chat Completions 协议转换(2026-08-21)
3
- *
4
- * ## 为什么有这层
5
- *
6
- * SDK binary 永远说 Anthropic Messages;model-ingress 以前只会**转发**(上游也说 Anthropic)。
7
- * OpenCode Zen 的免费模型 Ox Alpha(x-preview-f-free)只有 OpenAI chat 格式能用工具
8
- * (Zen 给它架的 /v1/messages 桥一带 tools 就 [1210],08-21 四种写法探死),newapi 中转站
9
- * 同病。所以协议映射得自己做 —— 不上 gproxy(外部守护进程 + 四个已知洞 + 第二个 quirk
10
- * 真相源),在 ingress 里按上游 `protocol: 'openai-chat'` 分岔,其余上游一字不动。
11
- *
12
- * ## 映射要点(都是探针实测逼出来的,不是抄规范)
13
- *
14
- * - tool_result 里的图一律提到紧随其后的 user 消息里:tool 角色消息里放 image_url 上游挂死 120s
15
- * - tool_result 必须紧跟 assistant 的 tool_calls:Anthropic 一条 user 消息里 tool_result 与
16
- * 文本混排 → 先吐 role:tool 条,再吐 role:user 条(文本 + 提出来的图)
17
- * - thinking 块不回传(没有 signature 机制);assistant 历史里的 thinking 合成 reasoning_content
18
- * (models.dev 标 interleaved.field=reasoning_content,回传给模型接着想)
19
- * - Anthropic thinking 参数 → reasoning_effort(行内 reasoningEffort,Ox 三档 low|high|max)
20
- * - 流式:OpenAI chunk → 合成 message_start / content_block_* / message_delta / message_stop;
21
- * usage 在最后一个 chunk(stream_options.include_usage),Anthropic 口径 input 不含 cache 命中
22
- * - stop_reason:tool_calls→tool_use · stop→end_turn · length→max_tokens;有 tool_calls 但
23
- * finish 说 stop 也算 tool_use(CLI 认块不认 stop_reason,但别给它矛盾信号)
24
- * - **上游私货 finish_reason**(08-21):Zen 会吐 `finish_reason:"network_error"`(它到模型
25
- * 提供方那一跳断了),实测形态是挂 185 秒或快败 6~9 秒后零 delta 收场。这种值不在
26
- * STOP_MAP 里,以前落 `|| 'end_turn'` = 把上游故障包装成"成功的空回合",CLI 只能补一句
27
- * "你上一轮没有可见输出"再跑一整轮(真实代价:185s 空转 + 一整轮重来)。现在改成发
28
- * error 事件 —— **假上游实验实测:CLI 收到流中 error 会在 0.2~0.4 秒内原样重发,用户
29
- * 全程无感**,所以重试交给它,ingress 不自建(两层重试互不知情,只会让失败会话多占并发槽)
30
- * - **refusal 字段**:OpenAI 的拒答走 `delta.refusal` / `message.refusal` 而不是 content,
31
- * 我们以前整个没读 = 又一种"零可见输出的假成功"。当文本吐出去即可。
32
- * ⚠️ 别顺手把 content_filter 映射成 Anthropic 的 `refusal` stop_reason(gproxy 那么写):
33
- * 实测 CLI 见到 stop_reason=refusal 会弹「Start a new session」并丢弃随流正文,
34
- * 会话直接判死 —— 现有的 content_filter→end_turn 在 Claude Code 语境下才是对的
35
- */
36
- import { Transform } from 'node:stream';
37
-
38
- const STOP_MAP = { tool_calls: 'tool_use', stop: 'end_turn', length: 'max_tokens', content_filter: 'end_turn', function_call: 'tool_use' };
39
-
40
- /** finish_reason 是不是上游私货(不在 STOP_MAP 里)。null/undefined 不算 —— 那是"没给收尾原因",另有分支 */
41
- const isAlienFinish = (finish) => Boolean(finish) && !(finish in STOP_MAP);
42
-
43
- /**
44
- * 「半截」判据(08-21 晚,对齐 OpenCode 1.18.21 的 unknown-finish 续接):**已经说出正文、
45
- * 却没有可信的收尾原因**(无 finish_reason = 上游把流掐了;私货 finish 如 network_error)。
46
- * 这种响应我们照旧按 end_turn 交付(假上游实测:有可见输出后再发 error 事件 CLI **不重试**,
47
- * 只会把半截 + "Server error mid-response" 一起交给用户并判 is_error),
48
- * 但要标记出来让 session-loop 自动续接一轮 —— 否则半截答案就是最终答案。
49
- *
50
- * ⚠️ 出过 tool_call 的不算:那种半截 CLI 自己会治(坏 JSON → __unparsedToolInput →
51
- * 本地合成 InputValidationError tool_result → 模型自己重来,实测 8s 内自愈)。
52
- * 对它续接等于叠加,实测还会把回合拖进 max_turns。
53
- *
54
- * @returns {string|null} 原因串(进日志/审计),null = 不是半截
55
- */
56
- export function truncationReason({ finish, sawText, sawToolCall, doneSeen = false }) {
57
- if (!sawText || sawToolCall) return null;
58
- // ⭐ 上游好好地发了 `[DONE]` 只是末块没带 finish_reason(OpenAI 兼容实现里不罕见):
59
- // 那是**收完了**,不是被掐。少这一条判据的话,换一家这种脾气的上游就会每一轮都平白
60
- // 续接到封顶 —— 3 倍 token、3 倍延迟,外加一条冤枉用户的告警(fable 评审 P1,探针复现过)。
61
- if (!finish && doneSeen) return null;
62
- if (!finish) return 'no finish_reason';
63
- if (isAlienFinish(finish)) return `finish_reason='${finish}'`;
64
- return null;
65
- }
66
-
67
- function textOfBlocks(blocks) {
68
- if (typeof blocks === 'string') return blocks;
69
- if (!Array.isArray(blocks)) return '';
70
- return blocks.filter(b => b?.type === 'text' && typeof b.text === 'string').map(b => b.text).join('\n');
71
- }
72
-
73
- function imagePart(block) {
74
- const src = block?.source;
75
- if (!src) return null;
76
- if (src.type === 'base64' && src.data) return { type: 'image_url', image_url: { url: `data:${src.media_type || 'image/png'};base64,${src.data}` } };
77
- if (src.type === 'url' && src.url) return { type: 'image_url', image_url: { url: src.url } };
78
- return null;
79
- }
80
-
81
- /** tool_result.content → (text, images[])。图不留在 tool 消息里(上游挂死),拿出来给调用方放进 user 消息 */
82
- function splitToolResult(block) {
83
- const images = [];
84
- let text = '';
85
- if (typeof block.content === 'string') text = block.content;
86
- else if (Array.isArray(block.content)) {
87
- const parts = [];
88
- for (const inner of block.content) {
89
- if (inner?.type === 'text') parts.push(inner.text || '');
90
- else if (inner?.type === 'image') { const p = imagePart(inner); if (p) { images.push(p); parts.push('[image: see the image attached to the following user message]'); } }
91
- }
92
- text = parts.join('\n');
93
- }
94
- if (block.is_error && text) text = `[tool error] ${text}`;
95
- return { text, images };
96
- }
97
-
98
- /**
99
- * @param {object} parsed Anthropic Messages body(已过 transformForUpstream:model 已是 wireModel)
100
- * @param {{ reasoningEffort?: string, maxOutput?: number }} opts
101
- * @returns {object} OpenAI chat.completions body
102
- */
103
- export function toOpenAIChatRequest(parsed, opts = {}) {
104
- const out = { model: parsed.model, messages: [] };
105
- const sys = textOfBlocks(parsed.system);
106
- if (sys) out.messages.push({ role: 'system', content: sys });
107
-
108
- for (const msg of parsed.messages || []) {
109
- if (!msg) continue;
110
- if (msg.role === 'assistant') {
111
- const m = { role: 'assistant', content: '' };
112
- if (typeof msg.content === 'string') m.content = msg.content;
113
- else if (Array.isArray(msg.content)) {
114
- const texts = []; const thoughts = []; const calls = [];
115
- for (const b of msg.content) {
116
- if (b?.type === 'text') texts.push(b.text || '');
117
- else if (b?.type === 'thinking' && b.thinking) thoughts.push(b.thinking);
118
- else if (b?.type === 'tool_use') calls.push({ id: b.id, type: 'function', function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) } });
119
- }
120
- // 只有 thinking(被打断的回合)时 content 为空且无 tool_calls,部分 OpenAI 兼容后端会 400 —— 补个占位
121
- m.content = texts.join('\n') || (calls.length ? '' : '(no text)');
122
- if (thoughts.length) m.reasoning_content = thoughts.join('\n');
123
- if (calls.length) m.tool_calls = calls;
124
- }
125
- out.messages.push(m);
126
- continue;
127
- }
128
- // user:tool_result 先出(紧跟 tool_calls),其余文本/图合成一条 user
129
- if (typeof msg.content === 'string') { out.messages.push({ role: 'user', content: msg.content }); continue; }
130
- if (!Array.isArray(msg.content)) continue;
131
- const toolMsgs = []; const parts = []; const lifted = [];
132
- for (const b of msg.content) {
133
- if (b?.type === 'tool_result') {
134
- const { text, images } = splitToolResult(b);
135
- toolMsgs.push({ role: 'tool', tool_call_id: b.tool_use_id, content: text || '(empty)' });
136
- lifted.push(...images);
137
- } else if (b?.type === 'text') parts.push({ type: 'text', text: b.text || '' });
138
- else if (b?.type === 'image') { const p = imagePart(b); if (p) parts.push(p); }
139
- else if (b?.type === 'document') parts.push({ type: 'text', text: '[document attachment omitted: upstream cannot read documents]' });
140
- }
141
- out.messages.push(...toolMsgs);
142
- const all = [...parts, ...lifted];
143
- if (all.length) {
144
- const onlyText = all.every(p => p.type === 'text');
145
- out.messages.push({ role: 'user', content: onlyText ? all.map(p => p.text).join('\n') : all });
146
- }
147
- }
148
-
149
- if (Array.isArray(parsed.tools)) {
150
- const fns = parsed.tools
151
- .filter(t => t && t.name && (t.type === undefined || t.type === 'custom'))
152
- .map(t => ({ type: 'function', function: { name: t.name, description: t.description || '', parameters: t.input_schema || { type: 'object', properties: {} } } }));
153
- if (fns.length) out.tools = fns;
154
- }
155
- const tc = parsed.tool_choice;
156
- if (tc && out.tools) {
157
- if (tc.type === 'any') out.tool_choice = 'required';
158
- else if (tc.type === 'none') out.tool_choice = 'none';
159
- else if (tc.type === 'tool' && tc.name) out.tool_choice = { type: 'function', function: { name: tc.name } };
160
- else out.tool_choice = 'auto';
161
- }
162
-
163
- const cap = opts.maxOutput || 131072;
164
- out.max_tokens = Math.max(1, Math.min(Number(parsed.max_tokens) || cap, cap));
165
- if (typeof parsed.temperature === 'number') out.temperature = parsed.temperature;
166
- if (typeof parsed.top_p === 'number') out.top_p = parsed.top_p;
167
- if (Array.isArray(parsed.stop_sequences) && parsed.stop_sequences.length) out.stop = parsed.stop_sequences.slice(0, 4);
168
- if (parsed.stream) { out.stream = true; out.stream_options = { include_usage: true }; }
169
- // 档位只看行内 reasoningEffort:Anthropic 的 thinking 字段在进到这里之前已被 transformForUpstream
170
- // 按行内 thinking:'strip' 删掉(fable 评审抓的:以前以它存在为前提,档位从没发出去过)
171
- if (opts.reasoningEffort && parsed.thinking?.type !== 'disabled') out.reasoning_effort = opts.reasoningEffort;
172
- return out;
173
- }
174
-
175
- function usageFromOpenAI(u) {
176
- if (!u) return { input_tokens: 0, output_tokens: 0 };
177
- const cached = Number(u.prompt_tokens_details?.cached_tokens) || 0;
178
- const prompt = Number(u.prompt_tokens) || 0;
179
- return {
180
- input_tokens: Math.max(0, prompt - cached),
181
- output_tokens: Number(u.completion_tokens) || 0,
182
- cache_read_input_tokens: cached,
183
- cache_creation_input_tokens: 0,
184
- };
185
- }
186
-
187
- function parseArgs(s) {
188
- if (s == null || s === '') return {};
189
- try { return JSON.parse(s); } catch { return { _raw_arguments: String(s) }; }
190
- }
191
-
192
- /**
193
- * 非流式:OpenAI chat.completion → Anthropic message。
194
- * 返回 null = 别包成成功回合,调用方回 502(CLI 会重试)。两种情况返 null:
195
- * ① 没有 choices(上游 200 但给了错误体/空体)
196
- * ② finish_reason 是上游私货且零可见输出(流式那条 `_finish` 的孪生洞,同一张 STOP_MAP)
197
- */
198
- export function fromOpenAIChatResponse(json) {
199
- if (!json || !Array.isArray(json.choices) || !json.choices.length) return null;
200
- const choice = json.choices[0] || {};
201
- const m = choice.message || {};
202
- const content = [];
203
- if (m.reasoning_content) content.push({ type: 'thinking', thinking: String(m.reasoning_content), signature: '' });
204
- if (m.content) content.push({ type: 'text', text: String(m.content) });
205
- if (m.refusal) content.push({ type: 'text', text: String(m.refusal) });
206
- for (const c of m.tool_calls || []) {
207
- content.push({ type: 'tool_use', id: c.id || `call_${content.length}`, name: c.function?.name || '', input: parseArgs(c.function?.arguments) });
208
- }
209
- const hasTools = (m.tool_calls || []).length > 0;
210
- const hasVisible = hasTools || Boolean(m.content) || Boolean(m.refusal);
211
- if (isAlienFinish(choice.finish_reason) && !hasVisible) {
212
- console.warn(`[ingress/openai-chat] upstream finish_reason='${choice.finish_reason}' with no visible output — failing the turn (non-stream)`);
213
- return null;
214
- }
215
- if (!hasVisible) console.warn(`[ingress/openai-chat] finish_reason='${choice.finish_reason}' 收尾但零可见输出(thinking-only,非流式)—— CLI 会补一轮催促`);
216
- const stop_reason = hasTools ? 'tool_use' : (STOP_MAP[choice.finish_reason] || 'end_turn');
217
- return {
218
- id: json?.id || `msg_${Date.now()}`,
219
- type: 'message', role: 'assistant',
220
- model: json?.model || '',
221
- content, stop_reason, stop_sequence: null,
222
- usage: usageFromOpenAI(json?.usage),
223
- };
224
- }
225
-
226
- /** 上游错误体 → Anthropic 错误体(CLI 会把 message 原样显示) */
227
- export function toAnthropicError(status, bodyText) {
228
- let message = bodyText;
229
- try { const j = JSON.parse(bodyText); message = j?.error?.message || j?.message || bodyText; } catch { /* 非 JSON */ }
230
- const type = status === 401 || status === 403 ? 'authentication_error'
231
- : status === 429 ? 'rate_limit_error'
232
- : status >= 500 ? 'api_error' : 'invalid_request_error';
233
- return { type: 'error', error: { type, message: String(message).slice(0, 2000) } };
234
- }
235
-
236
- /**
237
- * 非流式响应的「半截」判定(流式那份住在 OpenAIToAnthropicSSE.truncated;两边同一张判据 truncationReason)。
238
- * @returns {string|null} 原因串,null = 不是半截
239
- */
240
- export function truncationOfChatResponse(json) {
241
- const choice = json?.choices?.[0];
242
- if (!choice) return null;
243
- const m = choice.message || {};
244
- return truncationReason({
245
- finish: choice.finish_reason,
246
- sawText: Boolean(m.content) || Boolean(m.refusal),
247
- sawToolCall: (m.tool_calls || []).length > 0,
248
- });
249
- }
250
-
251
- /**
252
- * 流式:OpenAI SSE chunk → Anthropic SSE 事件。Transform,直接 pipe。
253
- * 状态机:当前打开的块(thinking/text/tool_use 之一)+ tool_calls 按 index 映射到块号。
254
- */
255
- export class OpenAIToAnthropicSSE extends Transform {
256
- constructor({ model = '', label = '上游' } = {}) {
257
- super();
258
- this.model = model;
259
- this.label = label; // 上游的人话名字(错误文案用;CLI 会把 message 原样显示给用户)
260
- this.buf = '';
261
- this.started = false;
262
- this.done = false;
263
- this.blockIndex = -1; // 最后分配的块号
264
- this.open = null; // { kind: 'thinking'|'text'|'tool', index }
265
- this.toolBlocks = new Map(); // openai tool_call index → block index
266
- this.finish = null;
267
- this.usage = null;
268
- this.id = null;
269
- this.sawToolCall = false;
270
- this.sawText = false; // 有过可见正文(区分"只想没说"的早断流)
271
- this.failReason = null; // 本次以 error 事件收场的原因(forward 层据此记会话失败计数;null = 正常收尾)
272
- this.truncated = null; // 本次「半截」收场的原因(见 truncationReason;forward 层据此报给 session-loop 续接)
273
- this.cost = null; // 上游报的本次费用(美元,/zen/go 在 [DONE] 后补的 cost 字段;null = 上游没报)
274
- this.doneSeen = false; // 见过 [DONE]:之后只收 cost,收尾等 _flush
275
- this.attempts = 1; // 这条 SSE 一共打了几发上游(就地重发会 ++)
276
- this.attemptUsage = null; // 这一发的 usage
277
- this.attemptCost = null; // 这一发的 cost
278
- // ⭐ 一份数两个读者,口径不同,别混([[feedback-single-source-of-truth]]):
279
- // this.usage = **最后一发**的 usage → 进 message_delta 发给 CLI。CLI 拿它算"我的上下文多大",
280
- // 累加会让一次重发把 input 翻倍,长会话里提前触发压缩。
281
- // this.usageTotal = **所有发**的累计 → 进 onBilling。记账问的是"真烧了多少",失败那发也烧了。
282
- this.usageTotal = null;
283
- this.sawStreamError = false; // 流中途发过 error 事件:这条流已经死了,不许再重发也不许再收尾
284
- }
285
- _emit(event, data) { this.push(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); }
286
- _ensureStart(chunk) {
287
- if (this.started) return;
288
- this.started = true;
289
- this.id = chunk?.id || `msg_${Date.now()}`;
290
- if (chunk?.model) this.model = chunk.model;
291
- this._emit('message_start', { type: 'message_start', message: { id: this.id, type: 'message', role: 'assistant', model: this.model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } } });
292
- }
293
- _closeOpen() {
294
- if (!this.open) return;
295
- this._emit('content_block_stop', { type: 'content_block_stop', index: this.open.index });
296
- this.open = null;
297
- }
298
- _openBlock(kind, block) {
299
- this._closeOpen();
300
- this.blockIndex += 1;
301
- this.open = { kind, index: this.blockIndex };
302
- this._emit('content_block_start', { type: 'content_block_start', index: this.blockIndex, content_block: block });
303
- return this.blockIndex;
304
- }
305
- _handleChunk(chunk) {
306
- this._ensureStart(chunk);
307
- if (chunk.usage) this.attemptUsage = chunk.usage;
308
- if (chunk.cost != null) this.attemptCost = Number(chunk.cost);
309
- const choice = chunk.choices?.[0];
310
- if (!choice) return;
311
- const d = choice.delta || {};
312
- if (d.reasoning_content) {
313
- if (this.open?.kind !== 'thinking') this._openBlock('thinking', { type: 'thinking', thinking: '', signature: '' });
314
- this._emit('content_block_delta', { type: 'content_block_delta', index: this.open.index, delta: { type: 'thinking_delta', thinking: String(d.reasoning_content) } });
315
- }
316
- // content 与 refusal 都是"可见正文",进同一个 text 块(拒答也是模型说的话,原样吐给用户)
317
- for (const piece of [d.content, d.refusal]) {
318
- if (!piece) continue;
319
- this.sawText = true;
320
- if (this.open?.kind !== 'text') this._openBlock('text', { type: 'text', text: '' });
321
- this._emit('content_block_delta', { type: 'content_block_delta', index: this.open.index, delta: { type: 'text_delta', text: String(piece) } });
322
- }
323
- for (const tc of d.tool_calls || []) {
324
- // 按 index 分块;上游不带 index 时:带 id 的是新调用,否则续上一个
325
- const key = tc.index ?? (tc.id ? `id:${tc.id}` : this.lastToolKey);
326
- this.lastToolKey = key;
327
- this.sawToolCall = true;
328
- if (!this.toolBlocks.has(key)) {
329
- const idx = this._openBlock('tool', { type: 'tool_use', id: tc.id || `call_${key}`, name: tc.function?.name || '', input: {} });
330
- this.toolBlocks.set(key, idx);
331
- } else if (this.open?.kind !== 'tool' || this.open.index !== this.toolBlocks.get(key)) {
332
- // 上游交错回到旧的 tool_call(少见):Anthropic 块一旦 stop 不能再开,只能并进当前块号
333
- this._closeOpen();
334
- this.open = { kind: 'tool', index: this.toolBlocks.get(key) };
335
- }
336
- const args = tc.function?.arguments;
337
- if (args) this._emit('content_block_delta', { type: 'content_block_delta', index: this.toolBlocks.get(key), delta: { type: 'input_json_delta', partial_json: String(args) } });
338
- }
339
- if (choice.finish_reason) this.finish = choice.finish_reason;
340
- }
341
- /**
342
- * 一次上游往返结束(流关了/断了)。**只结账 + 给判决,不发收尾事件** —— 因为判决可能是
343
- * "这一发白跑了,再打一次",那时这条 SSE 还要继续用(forward 层的就地重发,见该文件)。
344
- * @returns {{ kind: 'complete'|'truncated'|'empty', reason?: string }}
345
- */
346
- attemptEnd() {
347
- this._foldAttemptTotals();
348
- this._closeOpen(); // 失败那一发开着的 thinking 块也要闭合,否则块永远悬着
349
- return this.verdict();
350
- }
351
-
352
- /**
353
- * 这一发算什么:
354
- * empty —— **零可见输出且收尾原因不可信**(没 finish / 私货 finish / 一个块都没开)。
355
- * 这是"白跑一发",可以就地重发(跟 OpenCode 对 unknown finish 的做法一个意思)。
356
- * truncated —— 说了一半被掐(判据见 truncationReason),照旧 end_turn 交付 + 标记续接。
357
- * complete —— 正常收尾。⭐ 已知 finish(stop/length)+ 零可见输出**不算 empty**:
358
- * 那是上游好好地告诉你"我就没话说",OpenCode 同样直接结束不重试。
359
- */
360
- verdict() {
361
- // 已经往流里发过 error 事件:这条流按协议就结束了(CLI 实测收到流中 error 会 0.2 秒静默重发,
362
- // 人早走了)。既不能重发上游(纯烧钱),也不该再补 message_stop(error 之后再接内容块是非法的)。
363
- if (this.sawStreamError) return { kind: 'errored', reason: 'upstream sent an error mid-stream' };
364
- if (!this.started || (this.blockIndex < 0 && !this.finish)) {
365
- return { kind: 'empty', reason: 'empty response' };
366
- }
367
- const zeroVisible = !this.sawText && !this.sawToolCall;
368
- if (zeroVisible && !this.finish) return { kind: 'empty', reason: 'stream ended before any visible output' };
369
- if (zeroVisible && isAlienFinish(this.finish)) return { kind: 'empty', reason: `finish_reason='${this.finish}' with no visible output` };
370
- const truncated = truncationReason({ finish: this.finish, sawText: this.sawText, sawToolCall: this.sawToolCall, doneSeen: this.doneSeen });
371
- if (truncated) return { kind: 'truncated', reason: truncated };
372
- return { kind: 'complete' };
373
- }
374
-
375
- /** 整条响应收尾:发 Anthropic 的收场事件(或 error 事件)。调一次就封口。 */
376
- finalize(v = this.verdict()) {
377
- if (this.done) return;
378
- this.done = true;
379
- if (v.kind === 'errored') { // error 事件已经发过了(块也已闭合),不再补任何事件
380
- this.failReason = this.failReason || v.reason;
381
- return;
382
- }
383
- if (v.kind === 'empty') {
384
- // 零可见输出且收尾不可信 —— forward 层已经就地重发过(额度用完了才走到这),
385
- // 别包装成"成功的空消息"让 CLI 当正常结束(那会触发它"你上一轮没有可见输出"的催促循环)
386
- this._ensureStart(null);
387
- this.failReason = v.reason;
388
- const msg = v.reason === 'empty response'
389
- ? `${this.label}返回了空响应,一个字都没有 —— 上游问题,已自动重发仍失败;稍后再发,或换个模型(upstream returned an empty response)`
390
- : v.reason.startsWith('finish_reason=')
391
- ? `${this.label}以 ${this.finish} 结束了这次请求,没有输出任何正文 —— 上游自己的链路出错,已自动重发仍失败;稍后再发,或换个模型(upstream ended with ${v.reason})`
392
- : `${this.label}在模型还在思考、还没输出正文时就结束了响应 —— 上游问题,已自动重发仍失败;稍后再发,或换个模型/思考档(upstream stream ended before any visible output)`;
393
- console.warn(`[ingress/openai-chat] 零可见输出收场(${v.reason})—— 重发额度已用完,这一轮判失败`);
394
- this._emit('error', { type: 'error', error: { type: 'api_error', message: msg } });
395
- return;
396
- }
397
- if (v.kind === 'truncated') {
398
- this.truncated = v.reason;
399
- console.warn(`[ingress/openai-chat] 半截收场(${v.reason})—— 按 end_turn 交付,标记待续接`);
400
- } else if (isAlienFinish(this.finish)) {
401
- console.warn(`[ingress/openai-chat] 未知 finish_reason='${this.finish}'(有可见输出);按 end_turn 收尾`);
402
- } else if (this.finish && !this.sawText && !this.sawToolCall) {
403
- // 已知 finish 但零可见块:上游明说自己收完了,不重发(跟 OpenCode 一致)。CLI 会补一轮催促
404
- console.warn(`[ingress/openai-chat] finish_reason='${this.finish}' 收尾但零可见输出(thinking-only)—— CLI 会补一轮催促`);
405
- }
406
- this._closeOpen();
407
- const stop_reason = this.sawToolCall ? 'tool_use' : (STOP_MAP[this.finish] || 'end_turn');
408
- this._emit('message_delta', { type: 'message_delta', delta: { stop_reason, stop_sequence: null }, usage: usageFromOpenAI(this.usage) });
409
- this._emit('message_stop', { type: 'message_stop' });
410
- }
411
-
412
- /**
413
- * 就地失败收尾:发一条 error 事件就封口(重发那几发拿到 4xx/5xx 时用)。
414
- * ⚠️ 必须走它而不是"手写 _emit + end":`done` 要在这里置上,否则 `_flush` 会再判决一次、
415
- * 补第二条 error,还会把 failReason 从真因(限流/鉴权)盖成"零可见输出"。
416
- */
417
- failWith(message, reason) {
418
- if (this.done) return;
419
- this.done = true;
420
- this.failReason = reason || 'upstream error';
421
- this._ensureStart(null);
422
- this._closeOpen();
423
- this._emit('error', { type: 'error', error: { type: 'api_error', message: String(message).slice(0, 2000) } });
424
- }
425
-
426
- /**
427
- * 开始新一发上游往返(就地重发时调)。**只重置"这一发"的状态**:
428
- * 块号 / 已发出的块 / 见过正文没 / 累计 usage 与 cost 全部保留 —— 它们属于这条 SSE 而不是某一发。
429
- */
430
- beginAttempt() {
431
- this.buf = '';
432
- this.finish = null;
433
- this.doneSeen = false;
434
- this.attemptUsage = null;
435
- this.toolBlocks = new Map(); // 新一发的 tool_call index 从 0 重来,别跟上一发的块号串了
436
- this.lastToolKey = undefined;
437
- this.attempts += 1;
438
- }
439
-
440
- /** 把这一发的 usage/cost 折进整条响应的累计(失败那一发也烧了上游的 token,账要算它) */
441
- _foldAttemptTotals() {
442
- const u = this.attemptUsage;
443
- if (u) {
444
- this.usage = u; // 给 CLI 的:最后一发的真实上下文大小
445
- const t = this.usageTotal || { prompt_tokens: 0, completion_tokens: 0 };
446
- this.usageTotal = {
447
- prompt_tokens: (t.prompt_tokens || 0) + (u.prompt_tokens || 0),
448
- completion_tokens: (t.completion_tokens || 0) + (u.completion_tokens || 0),
449
- prompt_tokens_details: { cached_tokens: (t.prompt_tokens_details?.cached_tokens || 0) + (u.prompt_tokens_details?.cached_tokens || 0) },
450
- completion_tokens_details: { reasoning_tokens: (t.completion_tokens_details?.reasoning_tokens || 0) + (u.completion_tokens_details?.reasoning_tokens || 0) },
451
- };
452
- this.attemptUsage = null;
453
- }
454
- if (this.attemptCost != null) {
455
- this.cost = (this.cost || 0) + this.attemptCost;
456
- this.attemptCost = null;
457
- }
458
- }
459
-
460
- _transform(chunk, _enc, cb) {
461
- this.buf += chunk.toString('utf8');
462
- let nl;
463
- while ((nl = this.buf.indexOf('\n')) >= 0) {
464
- const line = this.buf.slice(0, nl).replace(/\r$/, '');
465
- this.buf = this.buf.slice(nl + 1);
466
- if (!line.startsWith('data:')) continue;
467
- const payload = line.slice(5).trim();
468
- if (!payload) continue;
469
- // [DONE] 不立刻收尾:Zen(/zen/go 入口)在 [DONE] **之后**还补一条 {"choices":[],"cost":"0.00123"},
470
- // 那是上游报的真实费用(记账要它)。[DONE] 后只认 cost,别的都忽略;真正收尾在 _flush(上游关流)
471
- if (payload === '[DONE]') { this.doneSeen = true; continue; }
472
- let j;
473
- try { j = JSON.parse(payload); } catch { continue; }
474
- if (this.doneSeen || this.done) { if (j && j.cost != null) this.attemptCost = Number(j.cost); continue; }
475
- if (j?.error) { // 流中途的错误体:转成 Anthropic error 事件
476
- this._ensureStart(j);
477
- this._closeOpen(); // 先把开着的块闭合再发 error(顺序反了会出现 error 后面还跟 content_block_stop)
478
- this.sawStreamError = true;
479
- this._emit('error', { type: 'error', error: { type: 'api_error', message: String(j.error.message || j.error) } });
480
- continue;
481
- }
482
- try { this._handleChunk(j); } catch (err) { console.warn('[ingress/openai-chat] chunk handling failed:', err.message); }
483
- }
484
- cb();
485
- }
486
- _flush(cb) {
487
- // 单发场景(没人调 attemptEnd 手动收尾):走同一条判决 → 收尾的路,行为与从前一致
488
- if (!this.done) this.finalize(this.attemptEnd());
489
- cb();
490
- }
491
- }
1
+ /**
2
+ * lib/ingress/openai-chat.js — Anthropic Messages ⇄ OpenAI Chat Completions 协议转换(2026-08-21)
3
+ *
4
+ * ## 为什么有这层
5
+ *
6
+ * SDK binary 永远说 Anthropic Messages;model-ingress 以前只会**转发**(上游也说 Anthropic)。
7
+ * OpenCode Zen 的免费模型 Ox Alpha(x-preview-f-free)只有 OpenAI chat 格式能用工具
8
+ * (Zen 给它架的 /v1/messages 桥一带 tools 就 [1210],08-21 四种写法探死),newapi 中转站
9
+ * 同病。所以协议映射得自己做 —— 不上 gproxy(外部守护进程 + 四个已知洞 + 第二个 quirk
10
+ * 真相源),在 ingress 里按上游 `protocol: 'openai-chat'` 分岔,其余上游一字不动。
11
+ *
12
+ * ## 映射要点(都是探针实测逼出来的,不是抄规范)
13
+ *
14
+ * - tool_result 里的图一律提到紧随其后的 user 消息里:tool 角色消息里放 image_url 上游挂死 120s
15
+ * - tool_result 必须紧跟 assistant 的 tool_calls:Anthropic 一条 user 消息里 tool_result 与
16
+ * 文本混排 → 先吐 role:tool 条,再吐 role:user 条(文本 + 提出来的图)
17
+ * - thinking 块不回传(没有 signature 机制);assistant 历史里的 thinking 合成 reasoning_content
18
+ * (models.dev 标 interleaved.field=reasoning_content,回传给模型接着想)
19
+ * - Anthropic thinking 参数 → reasoning_effort(行内 reasoningEffort,Ox 三档 low|high|max)
20
+ * - 流式:OpenAI chunk → 合成 message_start / content_block_* / message_delta / message_stop;
21
+ * usage 在最后一个 chunk(stream_options.include_usage),Anthropic 口径 input 不含 cache 命中
22
+ * - stop_reason:tool_calls→tool_use · stop→end_turn · length→max_tokens;有 tool_calls 但
23
+ * finish 说 stop 也算 tool_use(CLI 认块不认 stop_reason,但别给它矛盾信号)
24
+ * - **上游私货 finish_reason**(08-21):Zen 会吐 `finish_reason:"network_error"`(它到模型
25
+ * 提供方那一跳断了),实测形态是挂 185 秒或快败 6~9 秒后零 delta 收场。这种值不在
26
+ * STOP_MAP 里,以前落 `|| 'end_turn'` = 把上游故障包装成"成功的空回合",CLI 只能补一句
27
+ * "你上一轮没有可见输出"再跑一整轮(真实代价:185s 空转 + 一整轮重来)。现在改成发
28
+ * error 事件 —— **假上游实验实测:CLI 收到流中 error 会在 0.2~0.4 秒内原样重发,用户
29
+ * 全程无感**,所以重试交给它,ingress 不自建(两层重试互不知情,只会让失败会话多占并发槽)
30
+ * - **refusal 字段**:OpenAI 的拒答走 `delta.refusal` / `message.refusal` 而不是 content,
31
+ * 我们以前整个没读 = 又一种"零可见输出的假成功"。当文本吐出去即可。
32
+ * ⚠️ 别顺手把 content_filter 映射成 Anthropic 的 `refusal` stop_reason(gproxy 那么写):
33
+ * 实测 CLI 见到 stop_reason=refusal 会弹「Start a new session」并丢弃随流正文,
34
+ * 会话直接判死 —— 现有的 content_filter→end_turn 在 Claude Code 语境下才是对的
35
+ */
36
+ import { Transform } from 'node:stream';
37
+
38
+ const STOP_MAP = { tool_calls: 'tool_use', stop: 'end_turn', length: 'max_tokens', content_filter: 'end_turn', function_call: 'tool_use' };
39
+
40
+ /** finish_reason 是不是上游私货(不在 STOP_MAP 里)。null/undefined 不算 —— 那是"没给收尾原因",另有分支 */
41
+ const isAlienFinish = (finish) => Boolean(finish) && !(finish in STOP_MAP);
42
+
43
+ /**
44
+ * 「半截」判据(08-21 晚,对齐 OpenCode 1.18.21 的 unknown-finish 续接):**已经说出正文、
45
+ * 却没有可信的收尾原因**(无 finish_reason = 上游把流掐了;私货 finish 如 network_error)。
46
+ * 这种响应我们照旧按 end_turn 交付(假上游实测:有可见输出后再发 error 事件 CLI **不重试**,
47
+ * 只会把半截 + "Server error mid-response" 一起交给用户并判 is_error),
48
+ * 但要标记出来让 session-loop 自动续接一轮 —— 否则半截答案就是最终答案。
49
+ *
50
+ * ⚠️ 出过 tool_call 的不算:那种半截 CLI 自己会治(坏 JSON → __unparsedToolInput →
51
+ * 本地合成 InputValidationError tool_result → 模型自己重来,实测 8s 内自愈)。
52
+ * 对它续接等于叠加,实测还会把回合拖进 max_turns。
53
+ *
54
+ * @returns {string|null} 原因串(进日志/审计),null = 不是半截
55
+ */
56
+ export function truncationReason({ finish, sawText, sawToolCall, doneSeen = false }) {
57
+ if (!sawText || sawToolCall) return null;
58
+ // ⭐ 上游好好地发了 `[DONE]` 只是末块没带 finish_reason(OpenAI 兼容实现里不罕见):
59
+ // 那是**收完了**,不是被掐。少这一条判据的话,换一家这种脾气的上游就会每一轮都平白
60
+ // 续接到封顶 —— 3 倍 token、3 倍延迟,外加一条冤枉用户的告警(fable 评审 P1,探针复现过)。
61
+ if (!finish && doneSeen) return null;
62
+ if (!finish) return 'no finish_reason';
63
+ if (isAlienFinish(finish)) return `finish_reason='${finish}'`;
64
+ return null;
65
+ }
66
+
67
+ function textOfBlocks(blocks) {
68
+ if (typeof blocks === 'string') return blocks;
69
+ if (!Array.isArray(blocks)) return '';
70
+ return blocks.filter(b => b?.type === 'text' && typeof b.text === 'string').map(b => b.text).join('\n');
71
+ }
72
+
73
+ function imagePart(block) {
74
+ const src = block?.source;
75
+ if (!src) return null;
76
+ if (src.type === 'base64' && src.data) return { type: 'image_url', image_url: { url: `data:${src.media_type || 'image/png'};base64,${src.data}` } };
77
+ if (src.type === 'url' && src.url) return { type: 'image_url', image_url: { url: src.url } };
78
+ return null;
79
+ }
80
+
81
+ /** tool_result.content → (text, images[])。图不留在 tool 消息里(上游挂死),拿出来给调用方放进 user 消息 */
82
+ function splitToolResult(block) {
83
+ const images = [];
84
+ let text = '';
85
+ if (typeof block.content === 'string') text = block.content;
86
+ else if (Array.isArray(block.content)) {
87
+ const parts = [];
88
+ for (const inner of block.content) {
89
+ if (inner?.type === 'text') parts.push(inner.text || '');
90
+ else if (inner?.type === 'image') { const p = imagePart(inner); if (p) { images.push(p); parts.push('[image: see the image attached to the following user message]'); } }
91
+ }
92
+ text = parts.join('\n');
93
+ }
94
+ if (block.is_error && text) text = `[tool error] ${text}`;
95
+ return { text, images };
96
+ }
97
+
98
+ /**
99
+ * @param {object} parsed Anthropic Messages body(已过 transformForUpstream:model 已是 wireModel)
100
+ * @param {{ reasoningEffort?: string, maxOutput?: number }} opts
101
+ * @returns {object} OpenAI chat.completions body
102
+ */
103
+ export function toOpenAIChatRequest(parsed, opts = {}) {
104
+ const out = { model: parsed.model, messages: [] };
105
+ const sys = textOfBlocks(parsed.system);
106
+ if (sys) out.messages.push({ role: 'system', content: sys });
107
+
108
+ for (const msg of parsed.messages || []) {
109
+ if (!msg) continue;
110
+ if (msg.role === 'assistant') {
111
+ const m = { role: 'assistant', content: '' };
112
+ if (typeof msg.content === 'string') m.content = msg.content;
113
+ else if (Array.isArray(msg.content)) {
114
+ const texts = []; const thoughts = []; const calls = [];
115
+ for (const b of msg.content) {
116
+ if (b?.type === 'text') texts.push(b.text || '');
117
+ else if (b?.type === 'thinking' && b.thinking) thoughts.push(b.thinking);
118
+ else if (b?.type === 'tool_use') calls.push({ id: b.id, type: 'function', function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) } });
119
+ }
120
+ // 只有 thinking(被打断的回合)时 content 为空且无 tool_calls,部分 OpenAI 兼容后端会 400 —— 补个占位
121
+ m.content = texts.join('\n') || (calls.length ? '' : '(no text)');
122
+ if (thoughts.length) m.reasoning_content = thoughts.join('\n');
123
+ if (calls.length) m.tool_calls = calls;
124
+ }
125
+ out.messages.push(m);
126
+ continue;
127
+ }
128
+ // user:tool_result 先出(紧跟 tool_calls),其余文本/图合成一条 user
129
+ if (typeof msg.content === 'string') { out.messages.push({ role: 'user', content: msg.content }); continue; }
130
+ if (!Array.isArray(msg.content)) continue;
131
+ const toolMsgs = []; const parts = []; const lifted = [];
132
+ for (const b of msg.content) {
133
+ if (b?.type === 'tool_result') {
134
+ const { text, images } = splitToolResult(b);
135
+ toolMsgs.push({ role: 'tool', tool_call_id: b.tool_use_id, content: text || '(empty)' });
136
+ lifted.push(...images);
137
+ } else if (b?.type === 'text') parts.push({ type: 'text', text: b.text || '' });
138
+ else if (b?.type === 'image') { const p = imagePart(b); if (p) parts.push(p); }
139
+ else if (b?.type === 'document') parts.push({ type: 'text', text: '[document attachment omitted: upstream cannot read documents]' });
140
+ }
141
+ out.messages.push(...toolMsgs);
142
+ const all = [...parts, ...lifted];
143
+ if (all.length) {
144
+ const onlyText = all.every(p => p.type === 'text');
145
+ out.messages.push({ role: 'user', content: onlyText ? all.map(p => p.text).join('\n') : all });
146
+ }
147
+ }
148
+
149
+ if (Array.isArray(parsed.tools)) {
150
+ const fns = parsed.tools
151
+ .filter(t => t && t.name && (t.type === undefined || t.type === 'custom'))
152
+ .map(t => ({ type: 'function', function: { name: t.name, description: t.description || '', parameters: t.input_schema || { type: 'object', properties: {} } } }));
153
+ if (fns.length) out.tools = fns;
154
+ }
155
+ const tc = parsed.tool_choice;
156
+ if (tc && out.tools) {
157
+ if (tc.type === 'any') out.tool_choice = 'required';
158
+ else if (tc.type === 'none') out.tool_choice = 'none';
159
+ else if (tc.type === 'tool' && tc.name) out.tool_choice = { type: 'function', function: { name: tc.name } };
160
+ else out.tool_choice = 'auto';
161
+ }
162
+
163
+ const cap = opts.maxOutput || 131072;
164
+ out.max_tokens = Math.max(1, Math.min(Number(parsed.max_tokens) || cap, cap));
165
+ if (typeof parsed.temperature === 'number') out.temperature = parsed.temperature;
166
+ if (typeof parsed.top_p === 'number') out.top_p = parsed.top_p;
167
+ if (Array.isArray(parsed.stop_sequences) && parsed.stop_sequences.length) out.stop = parsed.stop_sequences.slice(0, 4);
168
+ if (parsed.stream) { out.stream = true; out.stream_options = { include_usage: true }; }
169
+ // 档位只看行内 reasoningEffort:Anthropic 的 thinking 字段在进到这里之前已被 transformForUpstream
170
+ // 按行内 thinking:'strip' 删掉(fable 评审抓的:以前以它存在为前提,档位从没发出去过)
171
+ if (opts.reasoningEffort && parsed.thinking?.type !== 'disabled') out.reasoning_effort = opts.reasoningEffort;
172
+ return out;
173
+ }
174
+
175
+ function usageFromOpenAI(u) {
176
+ if (!u) return { input_tokens: 0, output_tokens: 0 };
177
+ const cached = Number(u.prompt_tokens_details?.cached_tokens) || 0;
178
+ const prompt = Number(u.prompt_tokens) || 0;
179
+ return {
180
+ input_tokens: Math.max(0, prompt - cached),
181
+ output_tokens: Number(u.completion_tokens) || 0,
182
+ cache_read_input_tokens: cached,
183
+ cache_creation_input_tokens: 0,
184
+ };
185
+ }
186
+
187
+ function parseArgs(s) {
188
+ if (s == null || s === '') return {};
189
+ try { return JSON.parse(s); } catch { return { _raw_arguments: String(s) }; }
190
+ }
191
+
192
+ /**
193
+ * 非流式:OpenAI chat.completion → Anthropic message。
194
+ * 返回 null = 别包成成功回合,调用方回 502(CLI 会重试)。两种情况返 null:
195
+ * ① 没有 choices(上游 200 但给了错误体/空体)
196
+ * ② finish_reason 是上游私货且零可见输出(流式那条 `_finish` 的孪生洞,同一张 STOP_MAP)
197
+ */
198
+ export function fromOpenAIChatResponse(json) {
199
+ if (!json || !Array.isArray(json.choices) || !json.choices.length) return null;
200
+ const choice = json.choices[0] || {};
201
+ const m = choice.message || {};
202
+ const content = [];
203
+ if (m.reasoning_content) content.push({ type: 'thinking', thinking: String(m.reasoning_content), signature: '' });
204
+ if (m.content) content.push({ type: 'text', text: String(m.content) });
205
+ if (m.refusal) content.push({ type: 'text', text: String(m.refusal) });
206
+ for (const c of m.tool_calls || []) {
207
+ content.push({ type: 'tool_use', id: c.id || `call_${content.length}`, name: c.function?.name || '', input: parseArgs(c.function?.arguments) });
208
+ }
209
+ const hasTools = (m.tool_calls || []).length > 0;
210
+ const hasVisible = hasTools || Boolean(m.content) || Boolean(m.refusal);
211
+ if (isAlienFinish(choice.finish_reason) && !hasVisible) {
212
+ console.warn(`[ingress/openai-chat] upstream finish_reason='${choice.finish_reason}' with no visible output — failing the turn (non-stream)`);
213
+ return null;
214
+ }
215
+ if (!hasVisible) console.warn(`[ingress/openai-chat] finish_reason='${choice.finish_reason}' 收尾但零可见输出(thinking-only,非流式)—— CLI 会补一轮催促`);
216
+ const stop_reason = hasTools ? 'tool_use' : (STOP_MAP[choice.finish_reason] || 'end_turn');
217
+ return {
218
+ id: json?.id || `msg_${Date.now()}`,
219
+ type: 'message', role: 'assistant',
220
+ model: json?.model || '',
221
+ content, stop_reason, stop_sequence: null,
222
+ usage: usageFromOpenAI(json?.usage),
223
+ };
224
+ }
225
+
226
+ /** 上游错误体 → Anthropic 错误体(CLI 会把 message 原样显示) */
227
+ export function toAnthropicError(status, bodyText) {
228
+ let message = bodyText;
229
+ try { const j = JSON.parse(bodyText); message = j?.error?.message || j?.message || bodyText; } catch { /* 非 JSON */ }
230
+ const type = status === 401 || status === 403 ? 'authentication_error'
231
+ : status === 429 ? 'rate_limit_error'
232
+ : status >= 500 ? 'api_error' : 'invalid_request_error';
233
+ return { type: 'error', error: { type, message: String(message).slice(0, 2000) } };
234
+ }
235
+
236
+ /**
237
+ * 非流式响应的「半截」判定(流式那份住在 OpenAIToAnthropicSSE.truncated;两边同一张判据 truncationReason)。
238
+ * @returns {string|null} 原因串,null = 不是半截
239
+ */
240
+ export function truncationOfChatResponse(json) {
241
+ const choice = json?.choices?.[0];
242
+ if (!choice) return null;
243
+ const m = choice.message || {};
244
+ return truncationReason({
245
+ finish: choice.finish_reason,
246
+ sawText: Boolean(m.content) || Boolean(m.refusal),
247
+ sawToolCall: (m.tool_calls || []).length > 0,
248
+ });
249
+ }
250
+
251
+ /**
252
+ * 流式:OpenAI SSE chunk → Anthropic SSE 事件。Transform,直接 pipe。
253
+ * 状态机:当前打开的块(thinking/text/tool_use 之一)+ tool_calls 按 index 映射到块号。
254
+ */
255
+ export class OpenAIToAnthropicSSE extends Transform {
256
+ constructor({ model = '', label = '上游' } = {}) {
257
+ super();
258
+ this.model = model;
259
+ this.label = label; // 上游的人话名字(错误文案用;CLI 会把 message 原样显示给用户)
260
+ this.buf = '';
261
+ this.started = false;
262
+ this.done = false;
263
+ this.blockIndex = -1; // 最后分配的块号
264
+ this.open = null; // { kind: 'thinking'|'text'|'tool', index }
265
+ this.toolBlocks = new Map(); // openai tool_call index → block index
266
+ this.finish = null;
267
+ this.usage = null;
268
+ this.id = null;
269
+ this.sawToolCall = false;
270
+ this.sawText = false; // 有过可见正文(区分"只想没说"的早断流)
271
+ this.failReason = null; // 本次以 error 事件收场的原因(forward 层据此记会话失败计数;null = 正常收尾)
272
+ this.truncated = null; // 本次「半截」收场的原因(见 truncationReason;forward 层据此报给 session-loop 续接)
273
+ this.cost = null; // 上游报的本次费用(美元,/zen/go 在 [DONE] 后补的 cost 字段;null = 上游没报)
274
+ this.doneSeen = false; // 见过 [DONE]:之后只收 cost,收尾等 _flush
275
+ this.attempts = 1; // 这条 SSE 一共打了几发上游(就地重发会 ++)
276
+ this.attemptUsage = null; // 这一发的 usage
277
+ this.attemptCost = null; // 这一发的 cost
278
+ // ⭐ 一份数两个读者,口径不同,别混([[feedback-single-source-of-truth]]):
279
+ // this.usage = **最后一发**的 usage → 进 message_delta 发给 CLI。CLI 拿它算"我的上下文多大",
280
+ // 累加会让一次重发把 input 翻倍,长会话里提前触发压缩。
281
+ // this.usageTotal = **所有发**的累计 → 进 onBilling。记账问的是"真烧了多少",失败那发也烧了。
282
+ this.usageTotal = null;
283
+ this.sawStreamError = false; // 流中途发过 error 事件:这条流已经死了,不许再重发也不许再收尾
284
+ }
285
+ _emit(event, data) { this.push(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); }
286
+ _ensureStart(chunk) {
287
+ if (this.started) return;
288
+ this.started = true;
289
+ this.id = chunk?.id || `msg_${Date.now()}`;
290
+ if (chunk?.model) this.model = chunk.model;
291
+ this._emit('message_start', { type: 'message_start', message: { id: this.id, type: 'message', role: 'assistant', model: this.model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } } });
292
+ }
293
+ _closeOpen() {
294
+ if (!this.open) return;
295
+ this._emit('content_block_stop', { type: 'content_block_stop', index: this.open.index });
296
+ this.open = null;
297
+ }
298
+ _openBlock(kind, block) {
299
+ this._closeOpen();
300
+ this.blockIndex += 1;
301
+ this.open = { kind, index: this.blockIndex };
302
+ this._emit('content_block_start', { type: 'content_block_start', index: this.blockIndex, content_block: block });
303
+ return this.blockIndex;
304
+ }
305
+ _handleChunk(chunk) {
306
+ this._ensureStart(chunk);
307
+ if (chunk.usage) this.attemptUsage = chunk.usage;
308
+ if (chunk.cost != null) this.attemptCost = Number(chunk.cost);
309
+ const choice = chunk.choices?.[0];
310
+ if (!choice) return;
311
+ const d = choice.delta || {};
312
+ if (d.reasoning_content) {
313
+ if (this.open?.kind !== 'thinking') this._openBlock('thinking', { type: 'thinking', thinking: '', signature: '' });
314
+ this._emit('content_block_delta', { type: 'content_block_delta', index: this.open.index, delta: { type: 'thinking_delta', thinking: String(d.reasoning_content) } });
315
+ }
316
+ // content 与 refusal 都是"可见正文",进同一个 text 块(拒答也是模型说的话,原样吐给用户)
317
+ for (const piece of [d.content, d.refusal]) {
318
+ if (!piece) continue;
319
+ this.sawText = true;
320
+ if (this.open?.kind !== 'text') this._openBlock('text', { type: 'text', text: '' });
321
+ this._emit('content_block_delta', { type: 'content_block_delta', index: this.open.index, delta: { type: 'text_delta', text: String(piece) } });
322
+ }
323
+ for (const tc of d.tool_calls || []) {
324
+ // 按 index 分块;上游不带 index 时:带 id 的是新调用,否则续上一个
325
+ const key = tc.index ?? (tc.id ? `id:${tc.id}` : this.lastToolKey);
326
+ this.lastToolKey = key;
327
+ this.sawToolCall = true;
328
+ if (!this.toolBlocks.has(key)) {
329
+ const idx = this._openBlock('tool', { type: 'tool_use', id: tc.id || `call_${key}`, name: tc.function?.name || '', input: {} });
330
+ this.toolBlocks.set(key, idx);
331
+ } else if (this.open?.kind !== 'tool' || this.open.index !== this.toolBlocks.get(key)) {
332
+ // 上游交错回到旧的 tool_call(少见):Anthropic 块一旦 stop 不能再开,只能并进当前块号
333
+ this._closeOpen();
334
+ this.open = { kind: 'tool', index: this.toolBlocks.get(key) };
335
+ }
336
+ const args = tc.function?.arguments;
337
+ if (args) this._emit('content_block_delta', { type: 'content_block_delta', index: this.toolBlocks.get(key), delta: { type: 'input_json_delta', partial_json: String(args) } });
338
+ }
339
+ if (choice.finish_reason) this.finish = choice.finish_reason;
340
+ }
341
+ /**
342
+ * 一次上游往返结束(流关了/断了)。**只结账 + 给判决,不发收尾事件** —— 因为判决可能是
343
+ * "这一发白跑了,再打一次",那时这条 SSE 还要继续用(forward 层的就地重发,见该文件)。
344
+ * @returns {{ kind: 'complete'|'truncated'|'empty', reason?: string }}
345
+ */
346
+ attemptEnd() {
347
+ this._foldAttemptTotals();
348
+ this._closeOpen(); // 失败那一发开着的 thinking 块也要闭合,否则块永远悬着
349
+ return this.verdict();
350
+ }
351
+
352
+ /**
353
+ * 这一发算什么:
354
+ * empty —— **零可见输出且收尾原因不可信**(没 finish / 私货 finish / 一个块都没开)。
355
+ * 这是"白跑一发",可以就地重发(跟 OpenCode 对 unknown finish 的做法一个意思)。
356
+ * truncated —— 说了一半被掐(判据见 truncationReason),照旧 end_turn 交付 + 标记续接。
357
+ * complete —— 正常收尾。⭐ 已知 finish(stop/length)+ 零可见输出**不算 empty**:
358
+ * 那是上游好好地告诉你"我就没话说",OpenCode 同样直接结束不重试。
359
+ */
360
+ verdict() {
361
+ // 已经往流里发过 error 事件:这条流按协议就结束了(CLI 实测收到流中 error 会 0.2 秒静默重发,
362
+ // 人早走了)。既不能重发上游(纯烧钱),也不该再补 message_stop(error 之后再接内容块是非法的)。
363
+ if (this.sawStreamError) return { kind: 'errored', reason: 'upstream sent an error mid-stream' };
364
+ if (!this.started || (this.blockIndex < 0 && !this.finish)) {
365
+ return { kind: 'empty', reason: 'empty response' };
366
+ }
367
+ const zeroVisible = !this.sawText && !this.sawToolCall;
368
+ if (zeroVisible && !this.finish) return { kind: 'empty', reason: 'stream ended before any visible output' };
369
+ if (zeroVisible && isAlienFinish(this.finish)) return { kind: 'empty', reason: `finish_reason='${this.finish}' with no visible output` };
370
+ const truncated = truncationReason({ finish: this.finish, sawText: this.sawText, sawToolCall: this.sawToolCall, doneSeen: this.doneSeen });
371
+ if (truncated) return { kind: 'truncated', reason: truncated };
372
+ return { kind: 'complete' };
373
+ }
374
+
375
+ /** 整条响应收尾:发 Anthropic 的收场事件(或 error 事件)。调一次就封口。 */
376
+ finalize(v = this.verdict()) {
377
+ if (this.done) return;
378
+ this.done = true;
379
+ if (v.kind === 'errored') { // error 事件已经发过了(块也已闭合),不再补任何事件
380
+ this.failReason = this.failReason || v.reason;
381
+ return;
382
+ }
383
+ if (v.kind === 'empty') {
384
+ // 零可见输出且收尾不可信 —— forward 层已经就地重发过(额度用完了才走到这),
385
+ // 别包装成"成功的空消息"让 CLI 当正常结束(那会触发它"你上一轮没有可见输出"的催促循环)
386
+ this._ensureStart(null);
387
+ this.failReason = v.reason;
388
+ const msg = v.reason === 'empty response'
389
+ ? `${this.label}返回了空响应,一个字都没有 —— 上游问题,已自动重发仍失败;稍后再发,或换个模型(upstream returned an empty response)`
390
+ : v.reason.startsWith('finish_reason=')
391
+ ? `${this.label}以 ${this.finish} 结束了这次请求,没有输出任何正文 —— 上游自己的链路出错,已自动重发仍失败;稍后再发,或换个模型(upstream ended with ${v.reason})`
392
+ : `${this.label}在模型还在思考、还没输出正文时就结束了响应 —— 上游问题,已自动重发仍失败;稍后再发,或换个模型/思考档(upstream stream ended before any visible output)`;
393
+ console.warn(`[ingress/openai-chat] 零可见输出收场(${v.reason})—— 重发额度已用完,这一轮判失败`);
394
+ this._emit('error', { type: 'error', error: { type: 'api_error', message: msg } });
395
+ return;
396
+ }
397
+ if (v.kind === 'truncated') {
398
+ this.truncated = v.reason;
399
+ console.warn(`[ingress/openai-chat] 半截收场(${v.reason})—— 按 end_turn 交付,标记待续接`);
400
+ } else if (isAlienFinish(this.finish)) {
401
+ console.warn(`[ingress/openai-chat] 未知 finish_reason='${this.finish}'(有可见输出);按 end_turn 收尾`);
402
+ } else if (this.finish && !this.sawText && !this.sawToolCall) {
403
+ // 已知 finish 但零可见块:上游明说自己收完了,不重发(跟 OpenCode 一致)。CLI 会补一轮催促
404
+ console.warn(`[ingress/openai-chat] finish_reason='${this.finish}' 收尾但零可见输出(thinking-only)—— CLI 会补一轮催促`);
405
+ }
406
+ this._closeOpen();
407
+ const stop_reason = this.sawToolCall ? 'tool_use' : (STOP_MAP[this.finish] || 'end_turn');
408
+ this._emit('message_delta', { type: 'message_delta', delta: { stop_reason, stop_sequence: null }, usage: usageFromOpenAI(this.usage) });
409
+ this._emit('message_stop', { type: 'message_stop' });
410
+ }
411
+
412
+ /**
413
+ * 就地失败收尾:发一条 error 事件就封口(重发那几发拿到 4xx/5xx 时用)。
414
+ * ⚠️ 必须走它而不是"手写 _emit + end":`done` 要在这里置上,否则 `_flush` 会再判决一次、
415
+ * 补第二条 error,还会把 failReason 从真因(限流/鉴权)盖成"零可见输出"。
416
+ */
417
+ failWith(message, reason) {
418
+ if (this.done) return;
419
+ this.done = true;
420
+ this.failReason = reason || 'upstream error';
421
+ this._ensureStart(null);
422
+ this._closeOpen();
423
+ this._emit('error', { type: 'error', error: { type: 'api_error', message: String(message).slice(0, 2000) } });
424
+ }
425
+
426
+ /**
427
+ * 开始新一发上游往返(就地重发时调)。**只重置"这一发"的状态**:
428
+ * 块号 / 已发出的块 / 见过正文没 / 累计 usage 与 cost 全部保留 —— 它们属于这条 SSE 而不是某一发。
429
+ */
430
+ beginAttempt() {
431
+ this.buf = '';
432
+ this.finish = null;
433
+ this.doneSeen = false;
434
+ this.attemptUsage = null;
435
+ this.toolBlocks = new Map(); // 新一发的 tool_call index 从 0 重来,别跟上一发的块号串了
436
+ this.lastToolKey = undefined;
437
+ this.attempts += 1;
438
+ }
439
+
440
+ /** 把这一发的 usage/cost 折进整条响应的累计(失败那一发也烧了上游的 token,账要算它) */
441
+ _foldAttemptTotals() {
442
+ const u = this.attemptUsage;
443
+ if (u) {
444
+ this.usage = u; // 给 CLI 的:最后一发的真实上下文大小
445
+ const t = this.usageTotal || { prompt_tokens: 0, completion_tokens: 0 };
446
+ this.usageTotal = {
447
+ prompt_tokens: (t.prompt_tokens || 0) + (u.prompt_tokens || 0),
448
+ completion_tokens: (t.completion_tokens || 0) + (u.completion_tokens || 0),
449
+ prompt_tokens_details: { cached_tokens: (t.prompt_tokens_details?.cached_tokens || 0) + (u.prompt_tokens_details?.cached_tokens || 0) },
450
+ completion_tokens_details: { reasoning_tokens: (t.completion_tokens_details?.reasoning_tokens || 0) + (u.completion_tokens_details?.reasoning_tokens || 0) },
451
+ };
452
+ this.attemptUsage = null;
453
+ }
454
+ if (this.attemptCost != null) {
455
+ this.cost = (this.cost || 0) + this.attemptCost;
456
+ this.attemptCost = null;
457
+ }
458
+ }
459
+
460
+ _transform(chunk, _enc, cb) {
461
+ this.buf += chunk.toString('utf8');
462
+ let nl;
463
+ while ((nl = this.buf.indexOf('\n')) >= 0) {
464
+ const line = this.buf.slice(0, nl).replace(/\r$/, '');
465
+ this.buf = this.buf.slice(nl + 1);
466
+ if (!line.startsWith('data:')) continue;
467
+ const payload = line.slice(5).trim();
468
+ if (!payload) continue;
469
+ // [DONE] 不立刻收尾:Zen(/zen/go 入口)在 [DONE] **之后**还补一条 {"choices":[],"cost":"0.00123"},
470
+ // 那是上游报的真实费用(记账要它)。[DONE] 后只认 cost,别的都忽略;真正收尾在 _flush(上游关流)
471
+ if (payload === '[DONE]') { this.doneSeen = true; continue; }
472
+ let j;
473
+ try { j = JSON.parse(payload); } catch { continue; }
474
+ if (this.doneSeen || this.done) { if (j && j.cost != null) this.attemptCost = Number(j.cost); continue; }
475
+ if (j?.error) { // 流中途的错误体:转成 Anthropic error 事件
476
+ this._ensureStart(j);
477
+ this._closeOpen(); // 先把开着的块闭合再发 error(顺序反了会出现 error 后面还跟 content_block_stop)
478
+ this.sawStreamError = true;
479
+ this._emit('error', { type: 'error', error: { type: 'api_error', message: String(j.error.message || j.error) } });
480
+ continue;
481
+ }
482
+ try { this._handleChunk(j); } catch (err) { console.warn('[ingress/openai-chat] chunk handling failed:', err.message); }
483
+ }
484
+ cb();
485
+ }
486
+ _flush(cb) {
487
+ // 单发场景(没人调 attemptEnd 手动收尾):走同一条判决 → 收尾的路,行为与从前一致
488
+ if (!this.done) this.finalize(this.attemptEnd());
489
+ cb();
490
+ }
491
+ }