@mokuyoaxis/dsh-iris 0.3.0-alpha.1 → 0.3.0-beta.1

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 (174) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.en.md +20 -13
  3. package/README.md +20 -13
  4. package/bin/dsh-iris.js +143 -51
  5. package/docs/AGENT_CORE_QUICKSTART.md +2 -2
  6. package/docs/AGENT_QUICKSTART.md +1 -1
  7. package/docs/ARCHITECTURE.md +5 -5
  8. package/docs/ARTIFACT_MANIFEST.md +4 -0
  9. package/docs/AUDIO_FLOWS.md +20 -4
  10. package/docs/BETA_SCOPE.md +42 -0
  11. package/docs/CONTACT_SHEET.md +1 -1
  12. package/docs/CORE_CONNECTIONS.md +3 -1
  13. package/docs/CORE_QUICKSTART.md +7 -5
  14. package/docs/CORE_RUNTIME_CONTRACT.md +3 -3
  15. package/docs/CORE_SERVICE.md +9 -5
  16. package/docs/CORE_SKILLS.md +2 -2
  17. package/docs/DSH_COMPATIBILITY.md +11 -1
  18. package/docs/DSH_CORE_MIGRATION.md +6 -0
  19. package/docs/DSH_RUNTIME.md +2 -2
  20. package/docs/HEADLESS_CLI.md +29 -4
  21. package/docs/HOST_ADAPTER_CONTRACT.md +5 -3
  22. package/docs/IMAGE_FLOW.md +2 -2
  23. package/docs/IMAGE_GENERATION.md +1 -1
  24. package/docs/LOCAL_API.md +2 -2
  25. package/docs/LOCAL_API_CONTRACT.md +17 -15
  26. package/docs/LOCAL_CREATION.md +2 -2
  27. package/docs/LOCAL_OPERATIONS.md +2 -2
  28. package/docs/LOCAL_SELECTION.md +1 -1
  29. package/docs/LOCAL_WEB.md +11 -5
  30. package/docs/MCP.md +30 -9
  31. package/docs/MEDIA_SCHEDULING.md +72 -0
  32. package/docs/NETWORK_RESOURCES.md +106 -0
  33. package/docs/NETWORK_ROUTING.md +103 -0
  34. package/docs/NETWORK_WORKBENCH.md +65 -0
  35. package/docs/PANEL_EXTENSIONS.md +1 -1
  36. package/docs/PLUGINS.md +9 -5
  37. package/docs/PLUGIN_DEVELOPMENT.md +39 -0
  38. package/docs/PLUGIN_STARTUP.md +15 -2
  39. package/docs/PROVIDER_ADAPTER_CONTRACT.md +8 -2
  40. package/docs/PROVIDER_CREDENTIALS.md +67 -0
  41. package/docs/PROVIDER_EXTENSIONS.md +8 -4
  42. package/docs/PROVIDER_HEALTH.md +4 -2
  43. package/docs/PROVIDER_NETWORK.md +45 -0
  44. package/docs/PROVIDER_PLUGIN_CONTRACT.md +24 -0
  45. package/docs/PROVIDER_SETTINGS.md +13 -7
  46. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +3 -1
  47. package/docs/ROADMAP.md +6 -6
  48. package/docs/SECURITY.md +1 -1
  49. package/docs/SYNC_DELIVERY_RECOVERY.md +5 -1
  50. package/docs/VIDEO_FLOWS.md +89 -0
  51. package/docs/VISION_MODEL.md +2 -0
  52. package/docs/WEB_PROTOTYPE.md +3 -3
  53. package/docs/WEB_RECOVERY.md +2 -2
  54. package/docs/WORKBENCH_ARTIFACTS.md +6 -0
  55. package/docs/WORKBENCH_LOGIN.md +2 -2
  56. package/docs/releases/0.3.0-beta.1.md +34 -0
  57. package/docs/screenshots.md +2 -2
  58. package/lib/actions.js +5 -4
  59. package/lib/adapters.js +58 -41
  60. package/lib/api.js +23 -13
  61. package/lib/application-runtime.js +44 -15
  62. package/lib/application-writer.js +193 -0
  63. package/lib/client.d.ts +3 -3
  64. package/lib/client.js +62 -25
  65. package/lib/comfyui-adapter.js +3 -2
  66. package/lib/comfyui-client.js +10 -1
  67. package/lib/command-service.js +18 -5
  68. package/lib/config.js +13 -9
  69. package/lib/configured-image-runtime.js +28 -9
  70. package/lib/connection-skill.js +9 -0
  71. package/lib/core-artifact-store.js +68 -26
  72. package/lib/core-bridge-tools.js +5 -3
  73. package/lib/core-capability-catalog.js +21 -11
  74. package/lib/core-delivery-cache.js +8 -3
  75. package/lib/core-service-client.js +6 -2
  76. package/lib/core-tasks.js +86 -11
  77. package/lib/creation-request-store.js +8 -5
  78. package/lib/doctor.js +2 -2
  79. package/lib/dsh-core-adapter.js +61 -9
  80. package/lib/dsh-provider-config.js +1 -1
  81. package/lib/dsh-service-bridge.js +1 -0
  82. package/lib/flow-store.js +64 -17
  83. package/lib/image-flow.js +39 -17
  84. package/lib/index.js +10 -5
  85. package/lib/local-api-projection.js +6 -2
  86. package/lib/local-api.js +137 -49
  87. package/lib/local-creation.js +12 -5
  88. package/lib/local-operations.js +2 -2
  89. package/lib/local-request-root.js +2 -2
  90. package/lib/local-web-assets.js +2 -0
  91. package/lib/mcp-http.js +82 -9
  92. package/lib/mcp-server.js +23 -1
  93. package/lib/media-scheduler.js +194 -0
  94. package/lib/model-directory-service.js +25 -8
  95. package/lib/music-transport.js +4 -2
  96. package/lib/network-config-service.js +123 -0
  97. package/lib/network-config.js +127 -0
  98. package/lib/network-doctor.js +94 -0
  99. package/lib/network-management.js +51 -0
  100. package/lib/network-policy.js +19 -0
  101. package/lib/network-route-evidence.js +38 -0
  102. package/lib/network-routing.js +283 -0
  103. package/lib/operation-runner.js +83 -38
  104. package/lib/operation-store.js +6 -6
  105. package/lib/plugins.d.ts +25 -6
  106. package/lib/plugins.js +2 -1
  107. package/lib/provider-adapter.js +2 -2
  108. package/lib/provider-adapters.js +37 -5
  109. package/lib/provider-catalog.js +33 -16
  110. package/lib/provider-config-format.js +51 -0
  111. package/lib/provider-config-service.js +52 -22
  112. package/lib/provider-context.d.ts +90 -0
  113. package/lib/provider-context.js +42 -0
  114. package/lib/provider-credentials.js +97 -0
  115. package/lib/provider-diagnostics.js +5 -5
  116. package/lib/provider-health.js +10 -5
  117. package/lib/provider-monitor.js +5 -3
  118. package/lib/provider-network.js +52 -0
  119. package/lib/provider-protocol.js +3 -2
  120. package/lib/provider-registry.js +15 -5
  121. package/lib/provider-response.js +10 -0
  122. package/lib/provider-settings.js +14 -12
  123. package/lib/provider-task-runner.js +113 -37
  124. package/lib/provider-transport.js +16 -0
  125. package/lib/render.js +11 -1
  126. package/lib/scheduling-policy.js +66 -0
  127. package/lib/tasks.js +3 -2
  128. package/lib/video-flow-input.js +16 -0
  129. package/lib/video-input.js +12 -5
  130. package/lib/vision-model-routing.js +2 -0
  131. package/lib/web/app.js +107 -46
  132. package/lib/web/connections.js +151 -12
  133. package/lib/web/creation.js +29 -25
  134. package/lib/web/flow-view.js +4 -3
  135. package/lib/web/gallery.js +17 -16
  136. package/lib/web/index.html +58 -5
  137. package/lib/web/media-client.js +18 -4
  138. package/lib/web/network.js +232 -0
  139. package/lib/web/plugin-panel.js +4 -2
  140. package/lib/web/plugin-status.js +140 -0
  141. package/lib/web/providers.js +34 -13
  142. package/lib/web/recovery.js +2 -2
  143. package/lib/web/styles.css +54 -1
  144. package/lib/web/transport.js +6 -1
  145. package/lib/workbench-artifacts.js +3 -2
  146. package/package.json +5 -2
  147. package/skills/iris-core/references/comfyui.md +1 -1
  148. package/templates/plugin-starter/LICENSE +21 -0
  149. package/templates/plugin-starter/README.md +74 -0
  150. package/templates/plugin-starter/assets/peach.png +0 -0
  151. package/templates/plugin-starter/assets/violet.png +0 -0
  152. package/templates/plugin-starter/index.mjs +27 -0
  153. package/templates/plugin-starter/package.json +18 -0
  154. package/templates/plugin-starter/scripts/core-cli.mjs +20 -0
  155. package/templates/plugin-starter/scripts/environment.mjs +65 -0
  156. package/templates/plugin-starter/scripts/mcp-probe.mjs +22 -0
  157. package/templates/plugin-starter/scripts/setup.mjs +42 -0
  158. package/templates/plugin-starter/scripts/smoke.mjs +76 -0
  159. package/templates/plugin-starter/web/board.css +27 -0
  160. package/templates/plugin-starter/web/board.mjs +160 -0
  161. package/templates/plugin-starter/web/png.mjs +23 -0
  162. package/templates/provider-starter/LICENSE +21 -0
  163. package/templates/provider-starter/README.md +50 -0
  164. package/templates/provider-starter/index.d.mts +5 -0
  165. package/templates/provider-starter/index.mjs +165 -0
  166. package/templates/provider-starter/package.json +13 -0
  167. package/templates/provider-starter/scripts/core-cli.mjs +22 -0
  168. package/templates/provider-starter/scripts/environment.mjs +122 -0
  169. package/templates/provider-starter/scripts/fixture.mjs +54 -0
  170. package/templates/provider-starter/scripts/mcp-probe.mjs +21 -0
  171. package/templates/provider-starter/scripts/protocol-smoke.mjs +48 -0
  172. package/templates/provider-starter/scripts/setup.mjs +44 -0
  173. package/templates/provider-starter/scripts/smoke.mjs +145 -0
  174. package/user_guide.md +3 -3
@@ -0,0 +1,89 @@
1
+ # 文生视频 · 持久流程与资源交付
2
+
3
+ 这是 **0.3.0-beta.1 新增功能**。使用准确beta版本或npm `beta`通道;旧alpha.1没有下面的 `--video`、视频 Web 表单与 MCP 分组。原有 CLI / DSH 视频入口继续可用。
4
+
5
+ 协议按实际模型选择。`wan2.2-t2v-plus`等旧系列应对照官方[万相2.1–2.6文生视频接口](https://help.aliyun.com/zh/model-studio/legacy-wan-text-to-video-api-reference);官方当前主视频页已转向wan2.7新协议,不能仅凭同品牌模型套用其字段或声称已有实现覆盖。
6
+
7
+ ## 新建专用工作区
8
+
9
+ 以下路径必须是新的空 Flow 根:
10
+
11
+ ```bash
12
+ node bin/dsh-iris.js flow init --data-root /absolute/new-video-core
13
+ node bin/dsh-iris.js execution init --video --data-root /absolute/new-video-core
14
+ node bin/dsh-iris.js web serve --data-root /absolute/new-video-core \
15
+ --provider-config /absolute/private/providers.json --allow-execution \
16
+ --auth-store /absolute/private/video-auth \
17
+ --connection-store /absolute/private/video-connections \
18
+ --allow-connection-management --allow-mcp
19
+ ```
20
+
21
+ `--video` 显式建立 `creation-requests/v4`,同时包含原图片、语音与转写流程。不能与 `--audio` 或 `--image-generation` 混用;已有 v0–v3 根不升级、迁移或降级。旧版本在请求区门禁拒绝 v4;不要让旧版本 CLI 写入此根。
22
+
23
+ 初始化、读取目录、创建连接与保存视频意图均不生成。只有明确的首次继续调用模型,可能产生费用。模型池仍来自真实配置和既有用途规则;此固定流程要求用户选择一个实际 `modelRef`,不会跨请求轮询或悄悄切换模型。
24
+
25
+ beta.1修复统一检查明确的t2v/i2v/s2v/r2v模型家族:文生与图生候选不互选,固定文生视频Flow拒绝已知i2v/s2v/r2v模型。CLI/DSH和显式视频重试使用相同规则。没有这些家族标记的兼容模型ID延续原有t2v/i2v路径,实际支持仍由协议和上游决定;本片不新增模型元数据格式或迁移目录。
26
+
27
+ 独立 CLI `flow create/continue` 已补齐视频候选装配(beta.1修复),无需启动 Web:
28
+
29
+ ```bash
30
+ node bin/dsh-iris.js flow create --data-root /absolute/new-video-core \
31
+ --provider-config /absolute/private/providers.json \
32
+ --input '{"recipe":"video-generate/v0","model_ref":"provider::t2v-model","prompt":"鸢尾花随风轻摆","duration":5}'
33
+ node bin/dsh-iris.js flow continue run_0123456789abcdef01234567 \
34
+ --data-root /absolute/new-video-core --provider-config /absolute/private/providers.json
35
+ ```
36
+
37
+ Run ID 使用创建命令的实际返回值。首次继续仅提交一次;异步未结束时再次明确继续只观察原 Task,不重提。该固定流程仍仅文生视频;独立 `task retry` 的模式和媒体输入规则见[CLI 重试](HEADLESS_CLI.md#检查-task-与-artifact),不会因此扩展本流程范围。
38
+
39
+ ## 输入、任务和交付边界
40
+
41
+ - 固定配方为 `video-generate/v0`:明确 `modelRef`、`prompt`,可选 `size` 和数值型 `duration`(1–60 秒)。Prompt 最多20,000字符、尺寸最多64字符;具体参数支持由实际模型决定,目录不等于实测。
42
+ - 首片仅文生视频;不接受首帧、音频、S2V、任意供应商 JSON、本机路径或外部结果 URL。原 CLI / DSH 的图生视频和 S2V 入口不受影响。
43
+ - 创建预留唯一 Run/Task,并持久保存创建 UUID 和输入摘要;不立即提交。继续保存独立 Operation UUID,一次有限调用只提交或观察原 Task,受理未知不重新提交。
44
+ - 服务重启、刷新、历史读取不自动生成或查询供应商。异步任务需明确点击“查看原任务”再观察一次;停止等待只终止本地操作,不代表远端取消、退款或未计费。
45
+ - 原 Task 已成功但落盘失败时接回原交付;已有完整缓存可在无 Provider 的重启后修复。保留原 Run/Task/Operation/Artifact 身份。
46
+ - 视频为单一结果,无图片选择步骤。交付和读取限 MP4/WebM、最多 **32 MiB**;API 验证保存记录、大小及 SHA-256。没有完整编码探测、Range 或边下边播,客户端收到完整 Blob 后播放;真实模型的编码兼容性仍需验收。
47
+ - 类型或大小不符时保留原 Task 与已有交付证据,明确报错,不重新生成。工作台不输出供应商签名 URL、密钥或服务器存储路径。
48
+
49
+ beta.1修复会先检查 Manifest、媒体类型及实际普通文件大小,再读取固定流程的结果。超出32 MiB时保留已成功的原 Task/Artifact;重启后再次继续仍明确拒绝,不整读该文件或重新生成。这个上限属于固定 Flow/Core Web/MCP 视频读取入口;通用 Provider Task 的视频下载上限仍为1 GiB。DSH同源作品媒体GET按流发送、HEAD不分配完整文件缓冲,两者在发送前保留分块SHA-256校验;没有新增Range或编码探测。
50
+
51
+ Web 的 **创作 → 生成视频** 使用实际候选,参数默认折叠;结果提供原视频播放器和下载。**作品 → 视频** 点击“载入视频”才读取,不自动播放;离页、换分类和退出释放媒体。撤销执行权限的只读服务仍可按读取授权播放已有结果。浏览器恢复表只存身份,不存 Prompt 或凭据;丢失创建回包后接回原意图,不自动首次提交。
52
+
53
+ ## HTTP 与 MCP
54
+
55
+ HTTP 路径均以 `/api/v0` 开头,沿用 Bearer、Host/Origin、连接功能和具体工具授权:
56
+
57
+ | 入口 | 含义 |
58
+ |---|---|
59
+ | GET/HEAD `/video-models` | 真实池内文生视频候选;不探测,无密钥/端点 |
60
+ | POST `/videos` | JSON 意图,稳定 UUIDv4 `Idempotency-Key`;同 Key 同输入重放,不同输入409 |
61
+ | GET/HEAD `/artifacts/:id/video` | 授权后的原 MP4/WebM |
62
+ | GET/HEAD `/runs/:id/download` | 单一结果原字节及匹配扩展名 |
63
+ | Run / Operation 的既有入口 | 创建查询、有限继续、原操作查询和本地停止 |
64
+
65
+ JSON 正文最多64 KiB。v0–v3 根不开放此媒体组,旧连接和默认 image-edit 不自动扩权。MCP 由用户明确选择 `video-generate`,再分别选择 read/create/execute/stop 和需要的具体工具:
66
+
67
+ | 工具 | 行为 |
68
+ |---|---|
69
+ | `iris_core_video_models` | 读取实际候选 |
70
+ | `iris_core_create_video` | 保存意图,尚不生成 |
71
+ | `iris_core_video` | 校验并引用已保存的原视频,不调用模型 |
72
+
73
+ 继续、查询和停止复用原工具,并按原 Run 媒体组校验。MCP 无原生 video 内容块,视频工具返回标准 `resource_link`,URI 为 `iris://video/ARTIFACT_ID`。客户端通过标准 `resources/read` 获取 Base64 BlobResourceContents;每次读取重新检查 Core 当前授权。仅选择视频读取工具才注册资源模板,缩权或撤销后旧进程也无法继续读取。连接专属 Skill 由实际工具生成视频指导。
74
+
75
+ 客户端可能只显示引用,不提供播放器;拿到引用不等于看过视频。DSH 原生共享桥暂不投影视频资源,选择视频组时明确拒绝启动;使用通用 MCP、Core Web,或原 DSH 视频工具。
76
+
77
+ ## 验证范围
78
+
79
+ 以下测试和`scripts/`开发验收命令从源码仓库根运行,不随npm包分发。定向用例:`tests/video-flow.mjs`、`tests/video-api.mjs`、`tests/video-recovery.mjs`。真实 Chromium 场景:
80
+
81
+ ```bash
82
+ node scripts/verify-local-web-browser.mjs \
83
+ --browser-executable /absolute/path/to/chromium \
84
+ --output /absolute/new-video-evidence --video
85
+ ```
86
+
87
+ 覆盖实际小视频解码/播放、原字节下载、重启接回、丢失创建回包、停止、只读、窄屏和暗色;标准 SDK 的 HTTP/stdio 2025/2026 路径验证资源与权限。这些回归使用受控 Provider。
88
+
89
+ 另于2026-11-01完成百炼北京 `wan2.2-t2v-plus` 的真实单例:832×480、5秒H.264/MP4,1次生成提交、2次原任务查询;隔离服务重启接回同一Task、正式MCP资源读取、真实Chromium播放/下载与SHA一致均通过。未修改日常连接或查询实际账单。该结果仅覆盖这个模型、参数和当时环境,**不代表所有上游、账号、编码或客户端播放器均已实测**。
@@ -48,4 +48,6 @@ DSH rc.2 会压缩宿主附件。看图、OCR 和定位允许这种宿主规范
48
48
 
49
49
  [长图 OCR](OCR_MODEL.md)、[定位、拼图摘要与自述](COMPOSITE_VISION.md) 均已消费共享端口与整次预算;各自另验分块、bbox 解析、一张拼图一次生成及可选 Core 转写。legacy `vision.js` / Host `visionModel.analyze()` 兼容实现仍保留,现有业务入口不再调用。Model Port 的迁移切片本身不新增公开 SDK export 或生产依赖;0.2.1 提供 [视觉 CLI](VISION_CLI.md) 和可选 `visionInput`。复杂图像质量与 Android 浏览器仍需独立验证。
50
50
 
51
+ beta.1另修复旧 `adapters.visionStream` 兼容入口:累计SSE正文最多2 MiB,声明长度与实际字节均检查;外部取消及超时在发送前、收到响应后、逐块及结束时保留失败,已收到文字也不返回部分成功。主业务仍使用上文具有严格终态校验的Model Port,不把旧入口的宽松解析等同于该契约。
52
+
51
53
  契约与迁移说明见 [Model Port](MODEL_PORT_CONTRACT.md) 和 [DSH → Core](DSH_CORE_MIGRATION.md)。
@@ -2,7 +2,7 @@
2
2
 
3
3
  这是面向 **0.3.0** 的交互预览,包含创作、作品和连接草稿三个页面。首轮布局已围绕大图预览、紧凑参数面板和默认折叠的详情整理,仍用于验证操作方向,尚未定稿。
4
4
 
5
- Demo 可在源码树中独立预览,无需 DSH;使用内存演示与内置样图,不连接模型、不计费,也不写入 Core 数据。0.3 alpha源码的真实工作台已接入固定图片编辑及[独立文生图](IMAGE_GENERATION.md);npm 0.2.7 不含该页面,也不携带 prototypes/ 和预览脚本。
5
+ Demo 可在源码树中独立预览,无需 DSH;使用内存演示与内置样图,不连接模型、不计费,也不写入 Core 数据。0.3 beta的真实工作台已接入固定图片编辑及[独立文生图](IMAGE_GENERATION.md);npm 0.2.7 不含该页面,也不携带 prototypes/ 和预览脚本。
6
6
 
7
7
  ## 启动预览
8
8
 
@@ -46,9 +46,9 @@ node scripts/preview-workbench.mjs
46
46
 
47
47
  原型位于 `prototypes/workbench/`,使用原生 HTML/CSS/JavaScript,复用 Iris 图标和示例图片,没有新框架或生产依赖。运行详情和工具预览默认折叠,关闭显示“展开”、打开显示“收起”;提供浅/深色、键盘焦点与 320/390px 布局。
48
48
 
49
- 0.2.7 已有 [默认只读 API](LOCAL_API.md)及显式开启的 [本地上传/Run 创建](LOCAL_CREATION.md),带持久请求意图和身份接回;0.3 alpha源码另有 [Operation 与有限执行服务](CORE_SERVICE.md)、[版本约束选择](LOCAL_SELECTION.md)及真实创作页面。此 Demo 仍独立使用内存样例,其布局、提示词和样例效果用于说明方向,不代表模型能力;真实页面的恢复、故障与发布门禁继续独立验收。
49
+ 0.2.7 已有 [默认只读 API](LOCAL_API.md)及显式开启的 [本地上传/Run 创建](LOCAL_CREATION.md),带持久请求意图和身份接回;0.3 beta另有 [Operation 与有限执行服务](CORE_SERVICE.md)、[版本约束选择](LOCAL_SELECTION.md)及真实创作页面。此 Demo 仍独立使用内存样例,其布局、提示词和样例效果用于说明方向,不代表模型能力;真实页面的恢复、故障与发布门禁继续独立验收。
50
50
 
51
- [本地 API 契约](LOCAL_API_CONTRACT.md)区分已发布的只读/本地创建,以及未发布的有限执行、本地停止和 owner 持久选择。相同 Run 的 Task 复用、HTTP 创建幂等与选择的版本门禁分别验证;替换演示传输时仍须单独验证浏览器写入、原身份恢复与无 DSH 的完整流程。
51
+ [本地 API 契约](LOCAL_API_CONTRACT.md)区分已发布的只读/本地创建,以及0.3提供的有限执行、本地停止和 owner 持久选择。相同 Run 的 Task 复用、HTTP 创建幂等与选择的版本门禁分别验证;替换演示传输时仍须单独验证浏览器写入、原身份恢复与无 DSH 的完整流程。
52
52
 
53
53
  源码树可运行下面的检查;浏览器检查需显式提供已经安装的 Chromium,不下载或安装浏览器。无沙箱仅在受控测试环境确有需要时显式追加 `--no-sandbox`,不改变生产浏览器策略。
54
54
 
@@ -1,4 +1,4 @@
1
- # 工作台请求核对与刷新恢复(0.3 alpha)
1
+ # 工作台请求核对与刷新恢复(0.3 beta)
2
2
 
3
3
  本页描述源码中的 W1b 恢复及 W1c 页面接入。npm 0.2.7 不含这些新增接口与浏览器客户端;[真实工作台](LOCAL_WEB.md)已接入明确创作、选择和本地停止,“操作核对”默认折叠,自动恢复始终只读。
4
4
 
@@ -15,7 +15,7 @@ GET /api/v0/creation-requests?kind=flow-create&key=<original-lowercase-UUIDv4>
15
15
  {contractVersion:0, kind, state:"prepared"|"succeeded", result}
16
16
  ```
17
17
 
18
- 创建类型另包括 `generation-create`;新 v3 音频工作区增加 `audio-import`、`speech-create`、`transcription-create`,返回同样的有限身份,不返回音频或朗读正文。音频恢复与大小边界见[音频流程](AUDIO_FLOWS.md)。
18
+ 创建类型另包括 `generation-create`;新 v3 音频工作区增加 `audio-import`、`speech-create`、`transcription-create`,返回同样的有限身份,不返回音频或朗读正文。音频恢复与大小边界见[音频流程](AUDIO_FLOWS.md)。beta.1的 v4 根另有 `video-create`,同样只保存原 UUID/Run 身份,不存 Prompt;查询或找回已创建意图不自动首次生成,见[视频流程](VIDEO_FLOWS.md)。
19
19
 
20
20
  `result` 为 `{artifactId}`、`{runId}` 或 `null`。查询会核对原意图与已有结果;prepared 尚无目标时返回 null,已有目标核验通过后才返回引用。它不补写完成回执、不重建目标、不观察 Task 或调用 Provider。已推进或选择的 Run 不退回初始状态。没有原请求为 404;原结果不可用或证据损坏为 409,并保留原记录。
21
21
 
@@ -36,6 +36,12 @@ Core静态PNG/JPEG/WebP图片卡提供“改图”。填写修改指令,可选
36
36
 
37
37
  目录查询复用已提交的 Manifest 与派生 Index,只读取元数据和文件大小,不反复读取视频等媒体的完整字节、不重建索引或取得 Core writer 租约。打开媒体、看图/OCR、CLI inspect/list/export 保留原有完整内容哈希核验;目录里显示的哈希不表示本次浏览已经重新核验了全部文件内容。
38
38
 
39
+ beta.1把可兼容读取的早期Core提交记录单列为 `legacyCoreSkipped`,不计入损坏/缺失的 `droppedCore`。这些作品仍可按原ID检查或导出;需要编入当前目录时,由用户显式运行 `artifact rebuild --data-root /absolute/core`,该写操作可能升级早期记录。浏览和刷新不自动迁移,也不为分类重新哈希媒体;结构、大小或索引信息不符仍按不可读取处理。
40
+
41
+ DSH Core快照也复用这条元数据路径:兼容早期条目单列`legacySkipped`,坏条目单列`dropped`,正常作品继续显示。新作品提交对早期条目复用已校验索引中的摘要与大小,不读取历史媒体;缺少或不匹配的摘要要求显式重建,不补造内容证据。重建和库存刷新在写入前标记索引待刷新,失败后普通列表明确报错,成功发布新索引才清除标记;原作品记录和按ID读取保持。
42
+
43
+ beta.1中,DSH作品媒体GET先分块核验内容,再流式发送;HEAD同样验证哈希但不读取完整文件到内存。客户端断开或实例关闭会排空并关闭媒体流,原作品不变;不增加Range支持。图库索引未完成刷新时会明确报错并由Doctor提示,不以缺项的旧列表冒充完整目录;保留现场后可显式执行 `artifact rebuild`。
44
+
39
45
  OCR 动作 JSON 附 `status/totalChunks/successfulChunks/failedChunks/skippedChunks`,便于界面表达完整度。识别正文不自动保存为 Core Artifact。
40
46
 
41
47
  ## 选择、详情与批量下载
@@ -1,6 +1,6 @@
1
- # 工作台访问口令(0.3 alpha)
1
+ # 工作台访问口令(0.3 beta)
2
2
 
3
- 普通 `web serve` 使用一个访问口令,不要求账号、邮箱或读取 Token 文件。**稳定 npm 0.2.7 没有 `web serve`;下文针对0.3.0-alpha.1。** 源码树可把 `dsh-iris` 替换为 `node bin/dsh-iris.js`。
3
+ 普通 `web serve` 使用一个访问口令,不要求账号、邮箱或读取 Token 文件。**稳定 npm 0.2.7 没有 `web serve`;下文针对0.3.0-beta.1。** 源码树可把 `dsh-iris` 替换为 `node bin/dsh-iris.js`。
4
4
 
5
5
  ## 首次设置与日常使用
6
6
 
@@ -0,0 +1,34 @@
1
+ # Iris 0.3.0-beta.1: routing, scheduling and durable video
2
+
3
+ The first 0.3 beta builds on alpha.1's standalone Core workbench, generic MCP, ComfyUI and explicit plugins. It freezes the [minimal plugin contracts and main user flows](https://github.com/mokuyoaxis/dsh-iris/blob/v0.3.0-beta.1/docs/BETA_SCOPE.md) within this beta scope. The broader SDK remains experimental.
4
+
5
+ ```bash
6
+ npm install @mokuyoaxis/dsh-iris@0.3.0-beta.1
7
+ ```
8
+
9
+ The npm `beta` channel selects this release. Stable `latest` remains **0.2.7**, and `alpha` retains **0.3.0-alpha.1**. Follow the [Core quickstart](https://github.com/mokuyoaxis/dsh-iris/blob/v0.3.0-beta.1/docs/CORE_QUICKSTART.md) for a new workspace.
10
+
11
+ ## Added since alpha.1
12
+
13
+ - **Explicit network resources and routing.** Manage HTTP/HTTPS proxy nodes and fixed or ordered pools, bind a Provider, and inspect the route recorded for a task. An owner-only Web/CLI/API Doctor separates local configuration from an explicit bounded GET check. Unbound Providers inherit the actual process network environment; Iris does not configure Clash, control system TUN or infer geographic suitability.
14
+ - **Opt-in bounded media scheduling.** One physical root still has one writer. Network waits can overlap while short writes remain serialized, with separate HTTP concurrency, dispatch-rate and unfinished-remote-task limits. The default remains serial. Unknown acceptance never triggers automatic resubmission.
15
+ - **Durable text-to-video in Core Web and MCP.** A new v4 root adds actual model candidates, size/duration inputs, original Run/Task/Operation recovery, local stop and MP4/WebM playback/download up to 32 MiB. MCP exposes standard resource links and checks authorization on each read. Image-to-video, S2V, music and vision retain their existing CLI/DSH entries.
16
+ - **Environment credential references.** Explicit `apiKeyEnv` configuration stores a variable name and resolves its value inside Core. Inline keys remain supported. Conflicting inline/env/DSH sources are rejected; workflow reports and model-directory imports protect captured credentials. No automatic `.env` loading or host-configuration scanning is added.
17
+ - **Independent plugin starters.** The package includes a business/page/PNG-preview starter and a Wan asynchronous Provider starter. Provider factory context v1 lends Core's network port and resolved connection; Core owns scheduling, lifecycle, persistent tasks and storage. Plugin identities, exact dependencies and client grants remain explicit.
18
+ - **Clearer MCP onboarding and plugin status.** The workbench connects selection/authorization, configuration export and connection checks. Generic stdio/Streamable HTTP remain available with explicit client presets. Configuration inspection, protocol handshake and actual Agent use are separate facts.
19
+
20
+ ## Reliability fixes
21
+
22
+ Paid submissions do not automatically follow redirects or replay ambiguous requests. Queue rejection stays local to the affected Operation; accepted-task recovery preserves the original Attempt. Source-artifact permissions, multipart/Request interoperability, bounded MCP HTTP load, network-client retirement, media size checks and audio container metadata have been corrected.
23
+
24
+ Artifact index failures remain visible and recoverable. Normal commits refresh metadata without hashing the entire media library; explicit rebuild and media reads retain content verification. DSH screenshot delivery confirms that the requested Iris HTML route actually served the page before accepting a PNG. Video model families, transcription retries and the browser-only DSH client type declaration have also been aligned.
25
+
26
+ ## Compatibility and validation
27
+
28
+ Existing roots and grants are **not migrated or expanded automatically**. Video requires an explicitly initialized new v4 root; network bindings use a separate versioned configuration. Keep compatible readers and a complete backup when trying a newer format. DSH's existing exact host-version range remains unchanged. The Node.js minimum is **22.1.0**: isolated testing reproduced native fetch body-cancellation failures on 22.0.0, including without Iris, while 22.1.0 and current Node 22 pass. Package engines, Doctor and the minimum CI job use the same baseline.
29
+
30
+ Release checks cover the Ubuntu/Windows and minimum/current Node 22 matrix, actual tarball consumers, strict NodeNext public types and Chromium workbench scenarios. Controlled Providers and local proxies are distinguished from real accounts. Selected Alibaba Cloud image/video/audio paths have separate real-account evidence; this does not certify every model, account, GPU, browser or harness. Live ComfyUI GPU inference, automatic quotas/GC, weighted model/proxy rotation and a plugin marketplace remain outside this beta.
31
+
32
+ ## 中文摘要
33
+
34
+ beta.1 主要新增网络出口池与Doctor、有界并发调度、Core Web/MCP文生视频、环境凭据引用,以及随包分发的两类独立插件模板;同时完善MCP接入流程与插件状态,修复这轮审计确认的凭据、提交/恢复、媒体限额、索引和DSH集成问题。使用npm `beta`或准确版本安装,稳定版仍为0.2.7。最低Node为22.1.0;旧根不自动迁移,视频需显式新建v4根;最小契约收口不代表完整SDK或全部上游已验收。
@@ -2,9 +2,9 @@
2
2
 
3
3
  ## Core 工作台开发预览 / Core workbench development preview
4
4
 
5
- 2026-10-29 重拍,使用当前未发布源码、独立演示数据根及 Chrome for Testing 153.0.8010.12(Linux ARM64)。全新浏览器 profile 只使用内置明亮/暗色主题;流程与作品是此前从零使用演练已完成记录的独立副本,没有读取用户背景、真实供应商凭据或日常浏览器会话。
5
+ 2026-10-29 重拍,使用alpha.1发布前的源码、独立演示数据根及 Chrome for Testing 153.0.8010.12(Linux ARM64)。全新浏览器 profile 只使用内置明亮/暗色主题;流程与作品是此前从零使用演练已完成记录的独立副本,没有读取用户背景、真实供应商凭据或日常浏览器会话。
6
6
 
7
- Recaptured on 2026-10-29 from the current unreleased source in Chrome for Testing 153.0.8010.12 on Linux ARM64. A fresh browser profile uses only the built-in light/dark themes. The separate demo data root contains copies of completed records from the earlier first-use walkthrough. No personal background, real provider credentials or daily browser session is used.
7
+ Recaptured on 2026-10-29 from the pre-alpha.1 source in Chrome for Testing 153.0.8010.12 on Linux ARM64. A fresh browser profile uses only the built-in light/dark themes. The separate demo data root contains copies of completed records from the earlier first-use walkthrough. No personal background, real provider credentials or daily browser session is used.
8
8
 
9
9
  | 创作与下载 / Studio and download | 完整六类总览 / All six capabilities |
10
10
  |---|---|
package/lib/actions.js CHANGED
@@ -30,7 +30,7 @@ import { formatOcrResult } from './ocr.js';
30
30
  import { runOcrRequest } from './ocr-model-routing.js';
31
31
  import { ffmpegAvailable, extractFrames, probeVideo } from './media-probe.js';
32
32
  import { ensurePrivateDir, writePrivateFile } from './private-storage.js';
33
- import { selectMediaProtocol, selectImageProtocol, providerForImageModel, supportsImageEdit, supportsModelCapability, dashscopeTtsProtocolIssue, dashscopeTtsInputIssue } from './provider-protocol.js';
33
+ import { selectMediaProtocol, selectImageProtocol, providerForImageModel, providerApiKey, supportsImageEdit, supportsModelCapability, dashscopeTtsProtocolIssue, dashscopeTtsInputIssue } from './provider-protocol.js';
34
34
  import { musicCandidatesFromCatalog, providerTaskBinding } from './provider-catalog.js';
35
35
  import { invokeProviderOperation, providerPollTaskView, prepareProviderInput } from './provider-adapter.js';
36
36
  import { createConfiguredProviderAdapter as createProviderAdapter } from './provider-adapters.js';
@@ -43,6 +43,7 @@ import { ModelPortError } from './model-port-contract.js';
43
43
  import { compileComfyWorkflow, projectComfyWorkflow } from './comfyui-workflow.js';
44
44
  import { createComfyUiClient } from './comfyui-client.js';
45
45
  import { inspectComfyWorkflow } from './comfyui-inspection.js';
46
+ import { createProviderSecretScrubber } from './provider-credentials.js';
46
47
 
47
48
  const PROVIDER_ACTIONS = new Set(['image', 'image_edit', 'video', 'music', 'tts', 'transcribe', 'look', 'relook',
48
49
  'locate', 'ocr', 'media_summarize', 'task_reobserve', 'task_redeliver', 'task_manual_retry',
@@ -1104,7 +1105,7 @@ register('providers_list', async (host, args) => {
1104
1105
  ...selectMediaProtocol(p),
1105
1106
  visionInput: p.visionInput || null,
1106
1107
  directory: modelDirectorySummary(p),
1107
- apiKeyHint: p.dshProvider ? '由 DSH 管理' : String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
1108
+ apiKeyHint: p.dshProvider ? '由 DSH 管理' : p.apiKeyEnv ? '由环境变量提供' : String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
1108
1109
  models: models.providerModelsWithDirectory(p).map((m) => ({
1109
1110
  id: m.id,
1110
1111
  capabilities: m.capabilities,
@@ -1154,11 +1155,11 @@ register('providers_check_comfy_workflow', async (_host, args, { signal }) => {
1154
1155
  const file = path.join(store.irisHome(), 'providers.json'), before = fs.readFileSync(file);
1155
1156
  const workflow = compileComfyWorkflow(args.workflow !== undefined ? args.workflow : route.comfyWorkflow,
1156
1157
  { workflow_parameters: args.workflow_parameters });
1158
+ const scrub = createProviderSecretScrubber([route]);
1157
1159
  const report = await inspectComfyWorkflow(createComfyUiClient(route), workflow, { signal });
1158
1160
  signal?.throwIfAborted();
1159
1161
  if (!fs.readFileSync(file).equals(before)) throw Object.assign(new Error('检查期间配置已变化,请重新检查'), { code: 'IRIS_CONFIG_CHANGED' });
1160
- const key = route.apiKey;
1161
- return { ok: true, report: key ? JSON.parse(JSON.stringify(report).split(key).join('[redacted]')) : report };
1162
+ return { ok: true, report: scrub(report) };
1162
1163
  });
1163
1164
 
1164
1165
  /** 新增/更新供应商 */
package/lib/adapters.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * 所有函数只收显式 key;错误统一 throw Error(人话)。
9
9
  */
10
10
  import fs from 'node:fs';
11
+ import { inheritedProviderFetch } from './provider-network.js';
11
12
  import path from 'node:path';
12
13
  import { Readable } from 'node:stream';
13
14
  import { pipeline } from 'node:stream/promises';
@@ -146,10 +147,10 @@ function transportFetchError(err, response) {
146
147
  }
147
148
 
148
149
  /** 只包装网络读取;HTTP 业务错误和图片协议校验由调用方处理。 */
149
- async function fetchImageJson(url, options) {
150
+ async function fetchImageJson(url, options, fetchPort) {
150
151
  let response;
151
152
  try {
152
- response = await fetch(url, options);
153
+ response = await fetchPort(url, options);
153
154
  return { res: response, body: await readJson(response, response.ok ? PROVIDER_RESPONSE_LIMITS.mediaJson : PROVIDER_RESPONSE_LIMITS.json) };
154
155
  } catch (err) {
155
156
  if (err instanceof ProviderContractError) throw err;
@@ -191,11 +192,12 @@ export function applyImageSize({ protocol, mode, size }) {
191
192
  }
192
193
 
193
194
  /** 新版多模态同步协议:响应中直接返回图片 URL。 */
194
- export async function generateImageMultimodal({ key, baseUrl, model, prompt, image, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
195
+ export async function generateImageMultimodal({ key, baseUrl, model, prompt, image, size, n = 1, timeoutMs = T_SYNC_GEN, signal, fetchPort = inheritedProviderFetch }) {
195
196
  const apiBase = dashscopeApiBase(baseUrl);
196
197
  const requestSize = applyImageSize({ protocol: 'dashscope', mode: 'multimodal-sync', size });
197
198
  const { res, body } = await fetchImageJson(`${apiBase}/services/aigc/multimodal-generation/generation`, {
198
199
  method: 'POST',
200
+ redirect: 'error',
199
201
  headers: authHeaders(key),
200
202
  signal: timeoutSignal(timeoutMs, signal),
201
203
  body: JSON.stringify({
@@ -206,7 +208,7 @@ export async function generateImageMultimodal({ key, baseUrl, model, prompt, ima
206
208
  ] }] },
207
209
  parameters: { ...(requestSize ? { size: requestSize } : {}), n: Number(n) || 1 }
208
210
  })
209
- });
211
+ }, fetchPort);
210
212
  if (!res.ok) throw providerError(res.status, body, '生成失败', res.headers);
211
213
  const urls = [];
212
214
  for (const choice of (body && body.output && body.output.choices) || []) {
@@ -226,15 +228,16 @@ export async function startImageGeneration(opts) {
226
228
  }
227
229
  return { remoteTaskId: await submitImage(opts), urls: [] };
228
230
  }
229
- export async function submitImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SUBMIT, signal }) {
231
+ export async function submitImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SUBMIT, signal, fetchPort = inheritedProviderFetch }) {
230
232
  const apiBase = dashscopeApiBase(baseUrl);
231
233
  const requestSize = applyImageSize({ protocol: 'dashscope', mode: 'legacy-async', size });
232
234
  const { res, body } = await fetchImageJson(`${apiBase}/services/aigc/text2image/image-synthesis`, {
233
235
  method: 'POST',
236
+ redirect: 'error',
234
237
  headers: { ...authHeaders(key), 'X-DashScope-Async': 'enable' },
235
238
  signal: timeoutSignal(timeoutMs, signal),
236
239
  body: JSON.stringify({ model, input: { prompt }, parameters: { ...(requestSize ? { size: requestSize } : {}), n: Number(n) || 1 } })
237
- });
240
+ }, fetchPort);
238
241
  if (!res.ok) throw providerError(res.status, body, '提交失败', res.headers);
239
242
  const taskId = body && body.output && body.output.task_id;
240
243
  if (!taskId) throw new Error('提交成功但未返回 task_id');
@@ -250,7 +253,7 @@ export async function submitImage({ key, baseUrl, model, prompt, size, n = 1, ti
250
253
  * - 其他(t2v/i2v):POST /services/aigc/video-generation/video-synthesis,
251
254
  * imgDataUrl 支持公网 URL 或 data:image base64。
252
255
  */
253
- export async function submitVideo({ key, baseUrl, model, prompt, imgDataUrl, size = '1280*720', duration, audioUrl, resolution, timeoutMs = T_SUBMIT, signal }) {
256
+ export async function submitVideo({ key, baseUrl, model, prompt, imgDataUrl, size = '1280*720', duration, audioUrl, resolution, timeoutMs = T_SUBMIT, signal, fetchPort = inheritedProviderFetch }) {
254
257
  const apiBase = dashscopeApiBase(baseUrl);
255
258
  const isS2V = /s2v/i.test(model || '');
256
259
  let url, input, parameters;
@@ -267,11 +270,12 @@ export async function submitVideo({ key, baseUrl, model, prompt, imgDataUrl, siz
267
270
  url = `${apiBase}/services/aigc/video-generation/video-synthesis`;
268
271
  input = { prompt };
269
272
  if (imgDataUrl) input.img_url = imgDataUrl;
270
- parameters = { size };
273
+ parameters = { size: typeof size === 'string' ? size.replace(/^(\d+)[xX](\d+)$/, '$1*$2') : size };
271
274
  if (duration) parameters.duration = Number(duration);
272
275
  }
273
- const res = await fetch(url, {
276
+ const res = await fetchPort(url, {
274
277
  method: 'POST',
278
+ redirect: 'error',
275
279
  headers,
276
280
  signal: timeoutSignal(timeoutMs, signal),
277
281
  body: JSON.stringify({ model, input, parameters })
@@ -289,7 +293,7 @@ export async function submitVideo({ key, baseUrl, model, prompt, imgDataUrl, siz
289
293
  * 返回 oss:// 形式 URL;调用模型时请求头须带 X-DashScope-OssResourceResolve: enable
290
294
  * (submitVideo 的 s2v 分支已自动处理)。
291
295
  */
292
- export async function uploadTempFile({ key, baseUrl, model, filePath, bytes, filename, timeoutMs = T_UPLOAD, signal }) {
296
+ export async function uploadTempFile({ key, baseUrl, model, filePath, bytes, filename, timeoutMs = T_UPLOAD, signal, fetchPort = inheritedProviderFetch }) {
293
297
  if ((filePath === undefined) === (bytes === undefined)) throw new TypeError('上传须且只能提供本地路径或明确字节');
294
298
  const name = filePath !== undefined ? path.basename(filePath) : filename;
295
299
  if (typeof name !== 'string' || !name || name.length > 128 || /[\/\\\0]/.test(name)
@@ -298,7 +302,7 @@ export async function uploadTempFile({ key, baseUrl, model, filePath, bytes, fil
298
302
  const u = new URL(`${apiBase}/uploads`);
299
303
  u.searchParams.set('action', 'getPolicy');
300
304
  u.searchParams.set('model', model);
301
- const pres = await fetch(u, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
305
+ const pres = await fetchPort(u, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
302
306
  const pbody = await readJson(pres);
303
307
  if (!pres.ok || !pbody || !pbody.data) throw providerError(pres.status, pbody, '获取上传凭证失败', pres.headers);
304
308
  const pol = pbody.data;
@@ -312,7 +316,8 @@ export async function uploadTempFile({ key, baseUrl, model, filePath, bytes, fil
312
316
  fd.append('key', keyPath);
313
317
  fd.append('success_action_status', '200');
314
318
  fd.append('file', new Blob([bytes ?? fs.readFileSync(filePath)]), name);
315
- const up = await fetch(pol.upload_host, { method: 'POST', body: fd, signal: timeoutSignal(timeoutMs, signal) });
319
+ const up = await fetchPort(pol.upload_host, { method: 'POST', redirect: 'error', body: fd, signal: timeoutSignal(timeoutMs, signal) });
320
+ await up.body?.cancel();
316
321
  if (up.status !== 200) throw new Error(`临时上传失败:HTTP ${up.status}`);
317
322
  return 'oss://' + keyPath;
318
323
  }
@@ -320,9 +325,9 @@ export async function uploadTempFile({ key, baseUrl, model, filePath, bytes, fil
320
325
  const TERMINAL = new Set(['SUCCEEDED', 'FAILED', 'CANCELED', 'UNKNOWN']);
321
326
 
322
327
  /** @returns {{done:boolean, ok:boolean, urls:string[], message?:string}} */
323
- export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL, signal }) {
328
+ export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL, signal, fetchPort = inheritedProviderFetch }) {
324
329
  const apiBase = dashscopeApiBase(baseUrl);
325
- const res = await fetch(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
330
+ const res = await fetchPort(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
326
331
  headers: authHeaders(key),
327
332
  signal: timeoutSignal(timeoutMs, signal)
328
333
  });
@@ -363,10 +368,11 @@ export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL,
363
368
  }
364
369
 
365
370
  /* ---------------- DashScope:qwen-tts(同步) ---------------- */
366
- export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', text, voice = 'Cherry', timeoutMs = T_SYNC_GEN, signal }) {
371
+ export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', text, voice = 'Cherry', timeoutMs = T_SYNC_GEN, signal, fetchPort = inheritedProviderFetch }) {
367
372
  const apiBase = dashscopeApiBase(baseUrl);
368
- const res = await fetch(`${apiBase}/services/aigc/multimodal-generation/generation`, {
373
+ const res = await fetchPort(`${apiBase}/services/aigc/multimodal-generation/generation`, {
369
374
  method: 'POST',
375
+ redirect: 'error',
370
376
  headers: authHeaders(key),
371
377
  signal: timeoutSignal(timeoutMs, signal),
372
378
  body: JSON.stringify({ model, input: { text, voice } })
@@ -389,15 +395,16 @@ export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', t
389
395
  }
390
396
 
391
397
  /* ---------------- OpenAI Images 兼容(同步 base64/url) ---------------- */
392
- export async function openAiGenerateImage({ key, baseUrl, model, prompt, size = '1024x1024', n = 1, timeoutMs = T_SYNC_GEN, signal }) {
398
+ export async function openAiGenerateImage({ key, baseUrl, model, prompt, size = '1024x1024', n = 1, timeoutMs = T_SYNC_GEN, signal, fetchPort = inheritedProviderFetch }) {
393
399
  const base = String(baseUrl || '').replace(/\/+$/, '');
394
400
  const requestSize = applyImageSize({ protocol: 'openai-images', size });
395
401
  const { res, body } = await fetchImageJson(`${base}/images/generations`, {
396
402
  method: 'POST',
403
+ redirect: 'error',
397
404
  headers: authHeaders(key),
398
405
  signal: timeoutSignal(timeoutMs, signal),
399
406
  body: JSON.stringify({ model, prompt, size: requestSize, n: Number(n) || 1 })
400
- });
407
+ }, fetchPort);
401
408
  if (!res.ok) throw providerError(res.status, body, '生成失败', res.headers);
402
409
  const items = (body && Array.isArray(body.data) && body.data) || [];
403
410
  const out = [];
@@ -457,7 +464,7 @@ function chatImageItems(body, label = '聊天生图') {
457
464
  return items;
458
465
  }
459
466
 
460
- export async function openAiChatGenerateImage({ key, baseUrl, model, prompt, image, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
467
+ export async function openAiChatGenerateImage({ key, baseUrl, model, prompt, image, size, n = 1, timeoutMs = T_SYNC_GEN, signal, fetchPort = inheritedProviderFetch }) {
461
468
  if (typeof size === 'string' && size.trim()) {
462
469
  throw new ProviderContractError('聊天生图协议不支持 size 参数;请在提示词中描述所需尺寸', {
463
470
  stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
@@ -469,16 +476,16 @@ export async function openAiChatGenerateImage({ key, baseUrl, model, prompt, ima
469
476
  { type: 'image_url', image_url: { url: 'data:' + image.mediaType + ';base64,' + Buffer.from(image.bytes).toString('base64') } }
470
477
  ] : prompt;
471
478
  const { res, body } = await fetchImageJson(`${base}/chat/completions`, {
472
- method: 'POST', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
479
+ method: 'POST', redirect: 'error', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
473
480
  body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content }],
474
481
  ...(Number(n) > 1 ? { n: Number(n) } : {}) })
475
- });
482
+ }, fetchPort);
476
483
  if (!res.ok) throw providerError(res.status, body, '聊天生图失败', res.headers);
477
484
  return chatImageItems(body);
478
485
  }
479
486
 
480
487
  /** 同步 Responses 生图:主模型由 model 指定,图片工具模型沿用服务端默认。 */
481
- export async function openAiResponsesGenerateImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
488
+ export async function openAiResponsesGenerateImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SYNC_GEN, signal, fetchPort = inheritedProviderFetch }) {
482
489
  if (Number(n) !== 1) {
483
490
  throw new ProviderContractError('Responses 生图当前只支持 n=1', {
484
491
  stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
@@ -487,11 +494,11 @@ export async function openAiResponsesGenerateImage({ key, baseUrl, model, prompt
487
494
  const base = String(baseUrl || '').replace(/\/+$/, '');
488
495
  const requestSize = applyImageSize({ protocol: 'openai-responses-images', size });
489
496
  const { res, body } = await fetchImageJson(`${base}/responses`, {
490
- method: 'POST', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
497
+ method: 'POST', redirect: 'error', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
491
498
  body: JSON.stringify({ model, input: prompt, stream: false, store: false, background: false,
492
499
  tools: [{ type: 'image_generation', action: 'generate', ...(requestSize ? { size: requestSize } : {}) }],
493
500
  tool_choice: { type: 'image_generation' } })
494
- });
501
+ }, fetchPort);
495
502
  if (!res.ok) throw providerError(res.status, body, 'Responses 生图失败', res.headers);
496
503
  if (body?.status !== 'completed') throw imageResponseError('Responses 生图未返回同步完成结果;禁止自动重提');
497
504
  const output = Array.isArray(body.output) ? body.output : [];
@@ -515,7 +522,7 @@ export async function openAiResponsesGenerateImage({ key, baseUrl, model, prompt
515
522
  * 普通 OpenAI 兼容端点维持 GET /models 与字符串 id 返回形状。
516
523
  * @returns {Promise<Array<string|{id:string,capabilities?:string[]}>>}
517
524
  */
518
- export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal }) {
525
+ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal, fetchPort = inheritedProviderFetch }) {
519
526
  const base = String(baseUrl || '').trim().replace(/\/+$/, '');
520
527
  if (!base) throw new Error('iris: 供应商缺 baseUrl,无法发现模型');
521
528
  const dashscope = isDashScopeBaseUrl(base);
@@ -530,7 +537,7 @@ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal })
530
537
  url.searchParams.set('page_no', String(page));
531
538
  url.searchParams.set('page_size', '100');
532
539
  }
533
- const res = await fetch(url, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
540
+ const res = await fetchPort(url, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
534
541
  const body = await readJson(res);
535
542
  if (!res.ok) throw providerError(res.status, body, '模型发现失败', res.headers);
536
543
  if (!body || body.success === false) throw new ProviderContractError('模型目录响应无效,已保留上次完整目录', {
@@ -593,14 +600,17 @@ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal })
593
600
  * @param {AbortSignal} [signal] 取消传播
594
601
  * @returns {Promise<string>} 完整回答文本
595
602
  */
596
- export async function visionStream({ key, baseUrl, model = 'qwen-vl-plus', prompt, imageDataUrl, signal, timeoutMs = T_VISION }) {
603
+ export async function visionStream({ key, baseUrl, model = 'qwen-vl-plus', prompt, imageDataUrl, signal, timeoutMs = T_VISION, fetchPort = inheritedProviderFetch }) {
597
604
  const base = String(baseUrl || '').trim().replace(/\/+$/, '');
605
+ const requestSignal = timeoutSignal(timeoutMs, signal);
606
+ requestSignal.throwIfAborted();
598
607
  let res;
599
608
  try {
600
- res = await fetch(`${base}/chat/completions`, {
609
+ res = await fetchPort(`${base}/chat/completions`, {
601
610
  method: 'POST',
611
+ redirect: 'error',
602
612
  headers: authHeaders(key),
603
- signal: timeoutSignal(timeoutMs, signal),
613
+ signal: requestSignal,
604
614
  body: JSON.stringify({
605
615
  model,
606
616
  stream: true,
@@ -616,8 +626,10 @@ export async function visionStream({ key, baseUrl, model = 'qwen-vl-plus', promp
616
626
  })
617
627
  });
618
628
  } catch (err) {
629
+ requestSignal.throwIfAborted();
619
630
  throw networkError(err); // 网络层失败归类为 network
620
631
  }
632
+ if (requestSignal.aborted) { await res.body?.cancel(); requestSignal.throwIfAborted(); }
621
633
  if (!res.ok) {
622
634
  const text = await readProviderText(res, { maxBytes: PROVIDER_RESPONSE_LIMITS.json, stage: 'response', acceptance: 'unknown' });
623
635
  let body = null;
@@ -627,8 +639,8 @@ export async function visionStream({ key, baseUrl, model = 'qwen-vl-plus', promp
627
639
  let full = '';
628
640
  const decoder = new TextDecoder();
629
641
  let buffer = '';
630
- for await (const chunk of res.body) {
631
- if (signal && signal.aborted) break;
642
+ for await (const chunk of boundedProviderBody(res, { maxBytes: PROVIDER_RESPONSE_LIMITS.json, stage: 'response', acceptance: 'unknown' })) {
643
+ requestSignal.throwIfAborted();
632
644
  buffer += decoder.decode(chunk, { stream: true });
633
645
  let idx;
634
646
  while ((idx = buffer.indexOf('\n')) >= 0) {
@@ -646,6 +658,7 @@ export async function visionStream({ key, baseUrl, model = 'qwen-vl-plus', promp
646
658
  }
647
659
  }
648
660
  }
661
+ requestSignal.throwIfAborted();
649
662
  return full.trim();
650
663
  }
651
664
 
@@ -660,15 +673,15 @@ export function extFromUrl(url, fallback = 'bin') {
660
673
  return fallback;
661
674
  }
662
675
 
663
- export async function downloadTo(url, filePath, { timeoutMs = T_DOWNLOAD, signal, maxBytes = PROVIDER_RESPONSE_LIMITS.video } = {}) {
664
- const res = await fetch(url, { signal: timeoutSignal(timeoutMs, signal) });
676
+ export async function downloadTo(url, filePath, { timeoutMs = T_DOWNLOAD, signal, maxBytes = PROVIDER_RESPONSE_LIMITS.video, fetchPort = inheritedProviderFetch } = {}) {
677
+ ensurePrivateDir(path.dirname(filePath));
678
+ const tmp = privateSibling(filePath, 'download');
679
+ const res = await fetchPort(url, { signal: timeoutSignal(timeoutMs, signal) });
665
680
  if (!res.ok) {
666
681
  await res.body?.cancel();
667
682
  throw Object.assign(new Error(`下载结果失败:HTTP ${res.status}`), { stage: 'download', category: 'provider', httpStatus: res.status, acceptance: 'accepted' });
668
683
  }
669
684
  if (!res.body) throw new Error('下载结果失败:响应没有内容');
670
- ensurePrivateDir(path.dirname(filePath));
671
- const tmp = privateSibling(filePath, 'download');
672
685
  try {
673
686
  const output = fs.createWriteStream(tmp, { flags: 'wx', mode: 0o600 });
674
687
  const closed = new Promise(resolve => output.once('close', resolve));
@@ -688,7 +701,7 @@ export async function downloadTo(url, filePath, { timeoutMs = T_DOWNLOAD, signal
688
701
  * @param {{key:string, model:string, audioUrl:string}} opts audioUrl 为 oss:// 或公网 URL
689
702
  * @returns {Promise<string>} remoteTaskId
690
703
  */
691
- export async function submitTranscription({ key, baseUrl, model = 'qwen-audio-3.0-asr-flash-filetrans', audioUrl, timeoutMs = T_SUBMIT, signal }) {
704
+ export async function submitTranscription({ key, baseUrl, model = 'qwen-audio-3.0-asr-flash-filetrans', audioUrl, timeoutMs = T_SUBMIT, signal, fetchPort = inheritedProviderFetch }) {
692
705
  const apiBase = dashscopeApiBase(baseUrl);
693
706
  const headers = { ...authHeaders(key), 'X-DashScope-Async': 'enable' };
694
707
  if (/^oss:\/\//.test(audioUrl || '')) headers['X-DashScope-OssResourceResolve'] = 'enable';
@@ -696,8 +709,9 @@ export async function submitTranscription({ key, baseUrl, model = 'qwen-audio-3.
696
709
  const input = /^qwen3-asr-.*filetrans/i.test(model || '')
697
710
  ? { file_url: audioUrl }
698
711
  : { file_urls: [audioUrl] };
699
- const res = await fetch(`${apiBase}/services/audio/asr/transcription`, {
712
+ const res = await fetchPort(`${apiBase}/services/audio/asr/transcription`, {
700
713
  method: 'POST',
714
+ redirect: 'error',
701
715
  headers,
702
716
  signal: timeoutSignal(timeoutMs, signal),
703
717
  body: JSON.stringify({ model, input })
@@ -728,9 +742,9 @@ export function transcriptionText(payload) {
728
742
  }
729
743
 
730
744
  /** 轮询 ASR 任务;正式结果位于 results[].transcription_url 指向的 JSON。 */
731
- export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL, signal }) {
745
+ export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL, signal, fetchPort = inheritedProviderFetch }) {
732
746
  const apiBase = dashscopeApiBase(baseUrl);
733
- const res = await fetch(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
747
+ const res = await fetchPort(`${apiBase}/tasks/${encodeURIComponent(remoteTaskId)}`, {
734
748
  headers: authHeaders(key),
735
749
  signal: timeoutSignal(timeoutMs, signal)
736
750
  });
@@ -751,8 +765,11 @@ export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeou
751
765
  if (typeof result.transcription_text === 'string') text += result.transcription_text;
752
766
  else if (typeof result.text === 'string') text += result.text;
753
767
  if (status === 'SUCCEEDED' && result.subtask_status !== 'FAILED' && typeof result.transcription_url === 'string') {
754
- const transcriptRes = await fetch(result.transcription_url, { signal: timeoutSignal(timeoutMs, signal) });
755
- if (!transcriptRes.ok) throw new Error(`下载转写结果失败:HTTP ${transcriptRes.status}`);
768
+ const transcriptRes = await fetchPort(result.transcription_url, { signal: timeoutSignal(timeoutMs, signal) });
769
+ if (!transcriptRes.ok) {
770
+ await transcriptRes.body?.cancel();
771
+ throw new Error(`下载转写结果失败:HTTP ${transcriptRes.status}`);
772
+ }
756
773
  const transcriptBody = await readJson(transcriptRes);
757
774
  if (!transcriptBody) throw new Error('下载转写结果失败:响应不是 JSON');
758
775
  text += transcriptionText(transcriptBody);