@mokuyoaxis/dsh-iris 0.2.7 → 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 (234) hide show
  1. package/CHANGELOG.md +143 -1
  2. package/README.en.md +109 -311
  3. package/README.md +106 -309
  4. package/bin/dsh-iris.js +452 -92
  5. package/docs/AGENT_CORE_QUICKSTART.md +64 -0
  6. package/docs/AGENT_QUICKSTART.md +2 -0
  7. package/docs/ARCHITECTURE.md +13 -3
  8. package/docs/ARTIFACT_MANIFEST.md +4 -0
  9. package/docs/AUDIO_FLOWS.md +87 -0
  10. package/docs/BETA_SCOPE.md +42 -0
  11. package/docs/CONTACT_SHEET.md +62 -0
  12. package/docs/CORE_CONNECTIONS.md +101 -0
  13. package/docs/CORE_QUICKSTART.md +110 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +7 -2
  15. package/docs/CORE_SERVICE.md +101 -0
  16. package/docs/CORE_SKILLS.md +35 -3
  17. package/docs/DSH_COMPATIBILITY.md +21 -1
  18. package/docs/DSH_CORE_MIGRATION.md +6 -0
  19. package/docs/DSH_RC2_ADAPTATION.md +1 -1
  20. package/docs/DSH_RUNTIME.md +6 -2
  21. package/docs/HEADLESS_CLI.md +30 -5
  22. package/docs/HOST_ADAPTER_CONTRACT.md +5 -3
  23. package/docs/IMAGE_FLOW.md +4 -0
  24. package/docs/IMAGE_GENERATION.md +127 -0
  25. package/docs/LOCAL_API.md +4 -2
  26. package/docs/LOCAL_API_CONTRACT.md +42 -15
  27. package/docs/LOCAL_CREATION.md +7 -1
  28. package/docs/LOCAL_OPERATIONS.md +46 -0
  29. package/docs/LOCAL_SELECTION.md +57 -0
  30. package/docs/LOCAL_WEB.md +207 -0
  31. package/docs/MCP.md +212 -0
  32. package/docs/MEDIA_SCHEDULING.md +72 -0
  33. package/docs/MUSIC.md +2 -0
  34. package/docs/NETWORK_RESOURCES.md +106 -0
  35. package/docs/NETWORK_ROUTING.md +103 -0
  36. package/docs/NETWORK_WORKBENCH.md +65 -0
  37. package/docs/PANEL_EXTENSIONS.md +64 -0
  38. package/docs/PLUGINS.md +193 -0
  39. package/docs/PLUGIN_DEVELOPMENT.md +39 -0
  40. package/docs/PLUGIN_STARTUP.md +113 -0
  41. package/docs/PROVIDER_ADAPTER_CONTRACT.md +8 -2
  42. package/docs/PROVIDER_CREDENTIALS.md +67 -0
  43. package/docs/PROVIDER_EXTENSIONS.md +82 -0
  44. package/docs/PROVIDER_HEALTH.md +5 -1
  45. package/docs/PROVIDER_NETWORK.md +45 -0
  46. package/docs/PROVIDER_PLUGIN_CONTRACT.md +24 -0
  47. package/docs/PROVIDER_SETTINGS.md +91 -0
  48. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +8 -0
  49. package/docs/ROADMAP.md +18 -8
  50. package/docs/SECURITY.md +1 -1
  51. package/docs/SYNC_DELIVERY_RECOVERY.md +5 -1
  52. package/docs/VIDEO_FLOWS.md +89 -0
  53. package/docs/VISION_CLI.md +1 -1
  54. package/docs/VISION_MODEL.md +2 -0
  55. package/docs/WEB_PROTOTYPE.md +21 -6
  56. package/docs/WEB_RECOVERY.md +46 -0
  57. package/docs/WORKBENCH_ARTIFACTS.md +6 -0
  58. package/docs/WORKBENCH_LOGIN.md +63 -0
  59. package/docs/assets/screenshots/core-connections-preview.webp +0 -0
  60. package/docs/assets/screenshots/core-model-pools-preview.webp +0 -0
  61. package/docs/assets/screenshots/core-overview-preview.webp +0 -0
  62. package/docs/assets/screenshots/core-spectrum-preview.webp +0 -0
  63. package/docs/assets/screenshots/core-studio-dark-preview.webp +0 -0
  64. package/docs/assets/screenshots/core-studio-mobile-preview.webp +0 -0
  65. package/docs/assets/screenshots/core-studio-preview.webp +0 -0
  66. package/docs/releases/0.3.0-alpha.1.md +51 -0
  67. package/docs/releases/0.3.0-beta.1.md +34 -0
  68. package/docs/screenshots.md +41 -1
  69. package/lib/actions.js +56 -4
  70. package/lib/adapters.js +75 -49
  71. package/lib/api.js +23 -13
  72. package/lib/application-runtime.js +73 -17
  73. package/lib/application-writer.js +193 -0
  74. package/lib/audio-flow-input.js +37 -0
  75. package/lib/client.d.ts +3 -3
  76. package/lib/client.js +402 -72
  77. package/lib/comfyui-adapter.js +95 -0
  78. package/lib/comfyui-client.js +60 -0
  79. package/lib/comfyui-inspection.js +73 -0
  80. package/lib/comfyui-workflow.js +94 -0
  81. package/lib/command-service.js +18 -5
  82. package/lib/config.js +49 -10
  83. package/lib/configured-image-runtime.js +69 -0
  84. package/lib/connection-skill.js +98 -0
  85. package/lib/connection-template.js +23 -0
  86. package/lib/core-artifact-store.js +84 -28
  87. package/lib/core-bridge-selection.js +31 -0
  88. package/lib/core-bridge-tools.js +85 -0
  89. package/lib/core-capability-catalog.js +192 -0
  90. package/lib/core-connection-diagnostics.js +49 -0
  91. package/lib/core-connection-store.js +277 -0
  92. package/lib/core-connections.js +225 -0
  93. package/lib/core-delivery-cache.js +8 -3
  94. package/lib/core-maintenance.js +9 -3
  95. package/lib/core-runtime.js +2 -1
  96. package/lib/core-service-client.js +174 -0
  97. package/lib/core-tasks.js +113 -21
  98. package/lib/core-writer-lease.js +16 -0
  99. package/lib/creation-request-store.js +41 -64
  100. package/lib/doctor.js +2 -2
  101. package/lib/dsh-core-adapter.js +61 -9
  102. package/lib/dsh-host-adapter.js +4 -1
  103. package/lib/dsh-provider-config.js +1 -1
  104. package/lib/dsh-service-bridge.js +67 -0
  105. package/lib/execution-ownership.js +43 -0
  106. package/lib/flow-store.js +156 -14
  107. package/lib/generation-input.js +10 -1
  108. package/lib/headless-vision.js +7 -7
  109. package/lib/image-edit-input.js +1 -1
  110. package/lib/image-flow.js +97 -31
  111. package/lib/index.js +47 -22
  112. package/lib/local-api-projection.js +15 -6
  113. package/lib/local-api-token-directory.js +31 -0
  114. package/lib/local-api.js +861 -71
  115. package/lib/local-creation.js +96 -24
  116. package/lib/local-operations.js +36 -0
  117. package/lib/local-request-root.js +86 -0
  118. package/lib/local-web-assets.js +39 -0
  119. package/lib/mcp-bridge.js +58 -0
  120. package/lib/mcp-check.js +26 -0
  121. package/lib/mcp-config.js +42 -0
  122. package/lib/mcp-delivery.js +46 -0
  123. package/lib/mcp-http.js +174 -0
  124. package/lib/mcp-server.js +69 -0
  125. package/lib/media-scheduler.js +194 -0
  126. package/lib/model-directory-service.js +37 -10
  127. package/lib/model-directory.js +3 -3
  128. package/lib/models.js +2 -2
  129. package/lib/music-transport.js +6 -3
  130. package/lib/network-config-service.js +123 -0
  131. package/lib/network-config.js +127 -0
  132. package/lib/network-doctor.js +94 -0
  133. package/lib/network-management.js +51 -0
  134. package/lib/network-policy.js +19 -0
  135. package/lib/network-route-evidence.js +38 -0
  136. package/lib/network-routing.js +283 -0
  137. package/lib/operation-runner.js +281 -0
  138. package/lib/operation-store.js +216 -0
  139. package/lib/panel-extension.js +36 -0
  140. package/lib/plugin-contract.js +194 -0
  141. package/lib/plugin-execution.js +51 -0
  142. package/lib/plugin-launch.js +86 -0
  143. package/lib/plugin-operation-store.js +101 -0
  144. package/lib/plugin-operations.js +28 -0
  145. package/lib/plugin-registry.js +109 -0
  146. package/lib/plugin-selection.js +15 -0
  147. package/lib/plugin-source-image.js +5 -0
  148. package/lib/plugin-web-assets.js +54 -0
  149. package/lib/plugins.d.ts +307 -0
  150. package/lib/plugins.js +59 -0
  151. package/lib/provider-adapter.js +7 -3
  152. package/lib/provider-adapters.js +92 -22
  153. package/lib/provider-catalog.js +64 -36
  154. package/lib/provider-config-format.js +51 -0
  155. package/lib/provider-config-service.js +143 -31
  156. package/lib/provider-context.d.ts +90 -0
  157. package/lib/provider-context.js +42 -0
  158. package/lib/provider-credentials.js +97 -0
  159. package/lib/provider-diagnostics.js +56 -0
  160. package/lib/provider-extensions.js +29 -0
  161. package/lib/provider-health.js +18 -6
  162. package/lib/provider-monitor.js +81 -0
  163. package/lib/provider-network.js +52 -0
  164. package/lib/provider-protocol.js +73 -22
  165. package/lib/provider-registry.js +128 -0
  166. package/lib/provider-response.js +38 -0
  167. package/lib/provider-settings.js +136 -0
  168. package/lib/provider-task-runner.js +132 -43
  169. package/lib/provider-transport.js +16 -0
  170. package/lib/render.js +11 -1
  171. package/lib/scheduling-policy.js +66 -0
  172. package/lib/skill-installation.js +23 -11
  173. package/lib/tasks.js +3 -2
  174. package/lib/video-flow-input.js +16 -0
  175. package/lib/video-input.js +12 -5
  176. package/lib/vision-model-routing.js +5 -0
  177. package/lib/web/app.js +703 -0
  178. package/lib/web/appearance.js +111 -0
  179. package/lib/web/comfyui.js +146 -0
  180. package/lib/web/connections.js +516 -0
  181. package/lib/web/creation.js +359 -0
  182. package/lib/web/flow-view.js +34 -0
  183. package/lib/web/gallery.js +133 -0
  184. package/lib/web/index.html +344 -0
  185. package/lib/web/media-client.js +218 -0
  186. package/lib/web/network.js +232 -0
  187. package/lib/web/plugin-panel.js +223 -0
  188. package/lib/web/plugin-status.js +140 -0
  189. package/lib/web/plugin-surface.css +17 -0
  190. package/lib/web/providers.js +513 -0
  191. package/lib/web/recovery.js +105 -0
  192. package/lib/web/startup.js +18 -0
  193. package/lib/web/styles.css +571 -0
  194. package/lib/web/transport.js +101 -0
  195. package/lib/web/workbench-login.js +165 -0
  196. package/lib/workbench-artifacts.js +3 -2
  197. package/lib/workbench-auth-cli.js +47 -0
  198. package/lib/workbench-auth.js +200 -0
  199. package/package.json +24 -6
  200. package/plugins/contact-sheet/index.d.mts +4 -0
  201. package/plugins/contact-sheet/index.mjs +33 -0
  202. package/plugins/contact-sheet/web/board.css +14 -0
  203. package/plugins/contact-sheet/web/board.mjs +93 -0
  204. package/skills/iris-core/SKILL.md +8 -4
  205. package/skills/iris-core/references/cli.md +2 -2
  206. package/skills/iris-core/references/comfyui.md +69 -0
  207. package/skills/iris-core/references/tasks.md +2 -0
  208. package/templates/plugin-starter/LICENSE +21 -0
  209. package/templates/plugin-starter/README.md +74 -0
  210. package/templates/plugin-starter/assets/peach.png +0 -0
  211. package/templates/plugin-starter/assets/violet.png +0 -0
  212. package/templates/plugin-starter/index.mjs +27 -0
  213. package/templates/plugin-starter/package.json +18 -0
  214. package/templates/plugin-starter/scripts/core-cli.mjs +20 -0
  215. package/templates/plugin-starter/scripts/environment.mjs +65 -0
  216. package/templates/plugin-starter/scripts/mcp-probe.mjs +22 -0
  217. package/templates/plugin-starter/scripts/setup.mjs +42 -0
  218. package/templates/plugin-starter/scripts/smoke.mjs +76 -0
  219. package/templates/plugin-starter/web/board.css +27 -0
  220. package/templates/plugin-starter/web/board.mjs +160 -0
  221. package/templates/plugin-starter/web/png.mjs +23 -0
  222. package/templates/provider-starter/LICENSE +21 -0
  223. package/templates/provider-starter/README.md +50 -0
  224. package/templates/provider-starter/index.d.mts +5 -0
  225. package/templates/provider-starter/index.mjs +165 -0
  226. package/templates/provider-starter/package.json +13 -0
  227. package/templates/provider-starter/scripts/core-cli.mjs +22 -0
  228. package/templates/provider-starter/scripts/environment.mjs +122 -0
  229. package/templates/provider-starter/scripts/fixture.mjs +54 -0
  230. package/templates/provider-starter/scripts/mcp-probe.mjs +21 -0
  231. package/templates/provider-starter/scripts/protocol-smoke.mjs +48 -0
  232. package/templates/provider-starter/scripts/setup.mjs +44 -0
  233. package/templates/provider-starter/scripts/smoke.mjs +145 -0
  234. package/user_guide.md +5 -1
package/lib/plugins.js ADDED
@@ -0,0 +1,59 @@
1
+ // Experimental v0 public plugin author/embedding boundary. No automatic discovery or installation.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { createPluginRegistry } from './plugin-registry.js';
6
+ import { createApplicationRuntime } from './application-runtime.js';
7
+ import { definePlugin, pluginFail, pluginFields } from './plugin-contract.js';
8
+ import { createCoreCapabilityCatalog } from './core-capability-catalog.js';
9
+ import { pinPluginWebAssets, registerPluginWebAssets } from './plugin-web-assets.js';
10
+ import { createLocalReadApi } from './local-api.js';
11
+
12
+ export { createCoreCapabilityCatalog } from './core-capability-catalog.js';
13
+
14
+ export { definePlugin, PluginContractError, PLUGIN_CONTRACT_VERSION, PLUGIN_KINDS, PLUGIN_CORE_COMMANDS,
15
+ checkedPluginDependency, comparePluginVersions } from './plugin-contract.js';
16
+ export { defineProviderAdapter, ProviderContractError, normalizeMediaEndpoint } from './provider-extensions.js';
17
+ export { PROVIDER_FACTORY_CONTEXT_VERSION } from './provider-context.js';
18
+
19
+ export function createCorePluginRegistry({ plugins = [], coreVersion } = {}) {
20
+ coreVersion ??= JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
21
+ return createPluginRegistry({ plugins, coreVersion });
22
+ }
23
+
24
+ /** Importing a module executes trusted code. Compatibility gates activate, not module evaluation. */
25
+ export async function loadCorePluginRegistry({ entries = [], coreVersion } = {}) {
26
+ if (!Array.isArray(entries)) pluginFail('IRIS_PLUGIN_INVALID', '入口须为明确文件列表');
27
+ const plugins = [], assets = new Map();
28
+ for (const entry of entries) {
29
+ if (typeof entry !== 'string' || !path.isAbsolute(entry) || !/\.m?js$/.test(entry) || !fs.statSync(entry).isFile()) pluginFail('IRIS_PLUGIN_INVALID', '插件入口须为 JS/MJS 文件绝对路径');
30
+ const file = fs.realpathSync(entry), definition = definePlugin((await import(pathToFileURL(file).href)).default);
31
+ for (const [id, files] of pinPluginWebAssets(definition, file)) assets.set(id, files);
32
+ plugins.push(definition);
33
+ }
34
+ const registry = await createCorePluginRegistry({ plugins, coreVersion });
35
+ registerPluginWebAssets(registry, assets);
36
+ return registry;
37
+ }
38
+
39
+ /** Trusted Core assembly. Grants are captured once; individual calls cannot enlarge them. */
40
+ export function createCorePluginHost({ dataRoot, registry, allowedOperations = [] } = {}) {
41
+ const selection = createCoreCapabilityCatalog({ registry }).selectOperations(allowedOperations);
42
+ const grants = Object.freeze(selection.map(item => item.id)), application = createApplicationRuntime({ dataRoot });
43
+ return Object.freeze({
44
+ call: (id, input, { dependency, signal } = {}) => application.executePluginOperation(registry, id, input,
45
+ { allowedOperations: grants, dependency, signal }),
46
+ snapshot: () => application.snapshot(),
47
+ dispose: () => application.dispose()
48
+ });
49
+ }
50
+
51
+ /** Public, bounded Panel assembly. Media remains read-only; Provider editing is an explicit owner gate. */
52
+ export function createCorePluginWorkbench(options) {
53
+ if (!pluginFields(options, ['dataRoot', 'registry', 'panelContributions', 'panelManifestPath', 'providerConfigPath', 'allowProviderManagement', 'allowedOperations', 'connectionStore', 'allowMcp', 'workbenchAuth'], ['dataRoot'])) {
54
+ pluginFail('IRIS_PLUGIN_WEB_INVALID', '工作台只接受明确的数据根、注册器、Panel 选择及供应商管理设置');
55
+ }
56
+ const { dataRoot, registry: pluginRegistry, panelContributions, panelManifestPath, providerConfigPath, allowProviderManagement = false, allowedOperations, connectionStore, allowMcp = false, workbenchAuth } = options;
57
+ return createLocalReadApi({ dataRoot, serveWorkbench: true, pluginRegistry, panelContributions, panelManifestPath, providerConfigPath, allowProviderManagement,
58
+ allowedOperations, connectionStore, allowConnectionManagement: connectionStore !== undefined, allowMcp, workbenchAuth });
59
+ }
@@ -13,6 +13,7 @@ import {
13
13
  providerErrorRecord,
14
14
  resultFromThrown
15
15
  } from './provider-contract.js';
16
+ import { checkedPluginDependency } from './plugin-contract.js';
16
17
 
17
18
  export const PROVIDER_ADAPTER_CONTRACT_VERSION = 0;
18
19
 
@@ -72,7 +73,7 @@ export function defineProviderAdapter(input) {
72
73
  if (!isPlainObject(input)) throw new TypeError('Provider Adapter 必须是对象');
73
74
  const allowed = new Set([
74
75
  'contractVersion', 'id', 'protocol', 'capabilities', 'operations', 'unsupported',
75
- 'prepareInput', 'unsupportedPreparation'
76
+ 'prepareInput', 'unsupportedPreparation', 'pluginDependency'
76
77
  ]);
77
78
  for (const key of Object.keys(input)) {
78
79
  if (!allowed.has(key)) throw new Error('Provider Adapter 不允许顶层字段:' + key);
@@ -129,6 +130,8 @@ export function defineProviderAdapter(input) {
129
130
  }
130
131
  const unsupportedPreparation = input.prepareInput ? undefined
131
132
  : normalizedReason(input.unsupportedPreparation, '协议未实现临时输入上传');
133
+ const pluginDependency = input.pluginDependency === undefined ? undefined : checkedPluginDependency(input.pluginDependency);
134
+ if (pluginDependency && (pluginDependency.kind !== 'providers' || pluginDependency.contributionId !== protocol)) throw new TypeError('Provider Adapter 的插件依赖与协议不一致');
132
135
 
133
136
  return Object.freeze({
134
137
  contractVersion,
@@ -137,6 +140,7 @@ export function defineProviderAdapter(input) {
137
140
  capabilities: Object.freeze(capabilities),
138
141
  operations: Object.freeze(operations),
139
142
  unsupported: Object.freeze(unsupported),
143
+ ...(pluginDependency ? { pluginDependency } : {}),
140
144
  prepareInput: input.prepareInput,
141
145
  unsupportedPreparation
142
146
  });
@@ -192,7 +196,7 @@ export function providerAdapterSnapshot(adapter) {
192
196
  }
193
197
 
194
198
  /** 提交前的可选上传能力;不创建 Task,也不属于六个生命周期操作。 */
195
- export async function prepareProviderInput(adapter, input = {}) {
199
+ export async function prepareProviderInput(adapter, input = {}, operationContext = {}) {
196
200
  if (typeof adapter?.prepareInput !== 'function') {
197
201
  const error = new ProviderContractError(adapter?.unsupportedPreparation || '协议未实现临时输入上传', {
198
202
  stage: 'upload', category: 'invalid_request', acceptance: 'not_accepted',
@@ -202,7 +206,7 @@ export async function prepareProviderInput(adapter, input = {}) {
202
206
  throw error;
203
207
  }
204
208
  try {
205
- const value = await adapter.prepareInput(input);
209
+ const value = await adapter.prepareInput(input, operationContext);
206
210
  if (typeof value?.url !== 'string' || !value.url.trim()) {
207
211
  throw new ProviderContractError('Provider 输入准备必须返回非空 url', {
208
212
  stage: 'upload', category: 'protocol', acceptance: 'not_accepted'
@@ -10,10 +10,17 @@ import * as defaultTransport from './adapters.js';
10
10
  import { ProviderContractError, providerErrorRecord } from './provider-contract.js';
11
11
  import { writePrivateFile } from './private-storage.js';
12
12
  import { defineProviderAdapter } from './provider-adapter.js';
13
- import { selectMediaProtocol, providerApiKey, providerMediaBaseUrl, unsupportedProtocolError, supportsImageEdit, dashscopeTtsInputIssue } from './provider-protocol.js';
13
+ import { selectMediaProtocol, providerApiKey, providerMediaBaseUrl, unsupportedProtocolError, supportsImageEdit, dashscopeTtsInputIssue, BUILTIN_PROVIDER_PROTOCOLS } from './provider-protocol.js';
14
14
  import { modelRateLimit, reportModelRateLimit, isFreeQuotaExhausted, recordModelAvailability } from './provider-health.js';
15
15
  import { modelUnavailable, modelUnavailableReason, modelAvailabilityBinding } from './model-directory.js';
16
16
  import { prepareVisionImage, visionInputLimits } from './vision-image-input.js';
17
+ import { createComfyUiProviderAdapter } from './comfyui-adapter.js';
18
+ import { PROVIDER_RESPONSE_LIMITS } from './provider-response.js';
19
+ import { resolveProviderCredential, assertDiscoveryCredentials } from './provider-credentials.js';
20
+ import { assertProviderNetwork } from './provider-network.js';
21
+ import { providerNetworkRequirement, sameNetworkRequirement, networkPolicyError } from './network-policy.js';
22
+ import { createProviderTransport } from './provider-transport.js';
23
+ import { hasProviderContext } from './provider-context.js';
17
24
 
18
25
  const AMBIGUOUS_4XX = new Set([408, 409, 425, 499]);
19
26
 
@@ -26,7 +33,7 @@ function providerIdentity(provider) {
26
33
  return { id, key, baseUrl };
27
34
  }
28
35
 
29
- function mappedCategory(error, status) {
36
+ function mappedCategory(error, status, stage) {
30
37
  const raw = String(error?.category || '').toLowerCase();
31
38
  if (error?.name === 'AbortError' || error?.code === 'ABORT_ERR') return 'aborted';
32
39
  if (error?.name === 'TimeoutError' || raw === 'timeout'
@@ -37,7 +44,10 @@ function mappedCategory(error, status) {
37
44
  if (status === 429 || raw === 'rate_limit') return 'rate_limit';
38
45
  if (raw === 'invalid_parameter' || raw === 'invalid_request') return 'invalid_request';
39
46
  if (raw === 'protocol') return 'protocol';
47
+ if (raw === 'local_io') return 'local_io';
40
48
  if (raw === 'network' || error instanceof TypeError) return 'network';
49
+ if (!status && ['download', 'persist'].includes(stage)
50
+ && ['ENOSPC', 'EDQUOT', 'EACCES', 'EPERM', 'ENOENT', 'EEXIST', 'EROFS', 'EIO'].includes(error?.code)) return 'local_io';
41
51
  if (raw === 'server' || status >= 500) return 'provider';
42
52
  if (!status && /未返回|响应|协议|结构|json/i.test(String(error?.message || ''))) return 'protocol';
43
53
  return 'provider';
@@ -50,7 +60,7 @@ export function mapProviderAdapterError(error, context = {}) {
50
60
  ? statusValue
51
61
  : undefined;
52
62
  const stage = String(context.stage || error?.stage || 'submit');
53
- const category = mappedCategory(error, httpStatus);
63
+ const category = mappedCategory(error, httpStatus, stage);
54
64
  let acceptance = context.acceptance || error?.acceptance;
55
65
  if (stage === 'validate' || stage === 'prepare' || stage === 'upload') {
56
66
  acceptance = 'not_accepted';
@@ -117,8 +127,16 @@ function openAiImageArtifacts(items) {
117
127
  }
118
128
 
119
129
  function writeInlineArtifact(artifact, targetPath) {
130
+ const maxBytes = String(artifact.mediaType || '').startsWith('image/') ? PROVIDER_RESPONSE_LIMITS.image
131
+ : artifact.mediaType === 'text/plain' ? PROVIDER_RESPONSE_LIMITS.transcript : PROVIDER_RESPONSE_LIMITS.audio;
132
+ if (typeof artifact.data === 'string' && artifact.data.length > Math.ceil(maxBytes / 3) * 4) {
133
+ throw new ProviderContractError('内嵌媒体超过允许的大小', { stage: 'download', category: 'protocol', acceptance: 'accepted' });
134
+ }
120
135
  const data = String(artifact?.data || '').trim();
121
136
  const body = Buffer.from(data, 'base64');
137
+ if (body.length > maxBytes) {
138
+ throw new ProviderContractError('内嵌媒体超过允许的大小', { stage: 'download', category: 'protocol', acceptance: 'accepted' });
139
+ }
122
140
  if (!data || body.length === 0 || body.toString('base64') !== data) {
123
141
  throw new ProviderContractError('inline-base64 媒体数据无效', {
124
142
  stage: 'download', category: 'protocol', acceptance: 'accepted'
@@ -262,6 +280,8 @@ function operationsForDashScope(provider, transport) {
262
280
  return {
263
281
  bytes: await transport.downloadTo(artifact.url, targetPath, {
264
282
  signal: request.signal,
283
+ maxBytes: { image: PROVIDER_RESPONSE_LIMITS.image, video: PROVIDER_RESPONSE_LIMITS.video,
284
+ tts: PROVIDER_RESPONSE_LIMITS.audio, transcribe: PROVIDER_RESPONSE_LIMITS.transcript }[request.capability] || PROVIDER_RESPONSE_LIMITS.video,
265
285
  ...(request.timeoutMs ? { timeoutMs: request.timeoutMs } : {})
266
286
  })
267
287
  };
@@ -278,8 +298,8 @@ export function createDashScopeProviderAdapter(provider, { transport = defaultTr
278
298
  protocol: 'dashscope',
279
299
  capabilities: ['image', 'video', 'tts', 'transcribe'],
280
300
  operations: operationsForDashScope(provider, transport),
281
- async prepareInput({ model, filePath, signal, timeoutMs } = {}) {
282
- return { url: await transport.uploadTempFile({ key, baseUrl, model, filePath, signal, timeoutMs }) };
301
+ async prepareInput({ model, filePath, bytes, filename, signal, timeoutMs } = {}) {
302
+ return { url: await transport.uploadTempFile({ key, baseUrl, model, filePath, ...(bytes !== undefined ? { bytes, filename } : {}), signal, timeoutMs }) };
283
303
  },
284
304
  unsupported: {
285
305
  cancel: '当前 DashScope 媒体协议未提供经过验证的远端取消实现'
@@ -345,6 +365,7 @@ function operationsForOpenAiImages(provider, transport, protocol = 'openai-image
345
365
  return {
346
366
  bytes: await transport.downloadTo(artifact.url, targetPath, {
347
367
  signal: request.signal,
368
+ maxBytes: PROVIDER_RESPONSE_LIMITS.image,
348
369
  ...(request.timeoutMs ? { timeoutMs: request.timeoutMs } : {})
349
370
  })
350
371
  };
@@ -418,38 +439,78 @@ export function createGeminiWebMusicProviderAdapter(provider, { transport = defa
418
439
  });
419
440
  }
420
441
 
421
- const PROTOCOL_FACTORIES = new Map([
422
- ['gemini-web-music', createGeminiWebMusicProviderAdapter],
423
- ['dashscope', createDashScopeProviderAdapter],
424
- ['openai-images', createOpenAiImagesProviderAdapter],
425
- ['openai-chat-images', createOpenAiChatImagesProviderAdapter],
426
- ['openai-responses-images', createOpenAiResponsesImagesProviderAdapter]
427
- ]);
442
+ const PROTOCOL_FACTORIES = Object.freeze({
443
+ 'gemini-web-music': createGeminiWebMusicProviderAdapter,
444
+ dashscope: createDashScopeProviderAdapter,
445
+ 'openai-images': createOpenAiImagesProviderAdapter,
446
+ 'openai-chat-images': createOpenAiChatImagesProviderAdapter,
447
+ 'openai-responses-images': createOpenAiResponsesImagesProviderAdapter,
448
+ comfyui: createComfyUiProviderAdapter
449
+ });
450
+
451
+ export function builtinProviderExtension() {
452
+ return { contractVersion: 0, id: 'iris.builtin-media', version: '0.1.0',
453
+ protocols: BUILTIN_PROVIDER_PROTOCOLS.map(descriptor => ({ ...descriptor, createAdapter: PROTOCOL_FACTORIES[descriptor.id] })) };
454
+ }
428
455
 
429
456
  export function createConfiguredProviderAdapter(provider, options = {}) {
457
+ const network = options.network === undefined ? undefined : assertProviderNetwork(options.network);
458
+ const requirement = providerNetworkRequirement(provider);
459
+ if (requirement && (!network || !sameNetworkRequirement(requirement, network.networkRequirement) || network.providerId !== provider.id)) {
460
+ throw networkPolicyError('IRIS_NETWORK_CONTEXT_REQUIRED', '此供应商需要匹配的显式网络上下文,不会静默使用默认出口');
461
+ }
462
+ if (network && (options.transport !== undefined || options.fetchPort !== undefined)) {
463
+ throw new TypeError('network 不能与显式 transport 或 fetchPort 同时配置');
464
+ }
465
+ const factoryOptions = network ? { ...options, transport: createProviderTransport(network.fetch), fetchPort: network.fetch } : options;
466
+ provider = resolveProviderCredential(provider);
430
467
  const selection = options.protocol
431
468
  ? selectMediaProtocol({ ...provider, mediaProtocol: options.protocol, protocolInferred: false })
432
469
  : selectMediaProtocol(provider);
433
- const factory = PROTOCOL_FACTORIES.get(selection.mediaProtocol);
434
- if (!factory) throw unsupportedProtocolError(selection.mediaProtocol);
435
- const adapter = factory(provider, options);
470
+ const factory = Object.hasOwn(PROTOCOL_FACTORIES, selection.mediaProtocol) ? PROTOCOL_FACTORIES[selection.mediaProtocol] : null;
471
+ if (!options.registry && !factory) throw unsupportedProtocolError(selection.mediaProtocol);
472
+ const adapter = options.registry ? options.registry.createAdapter(selection.mediaProtocol, provider, factoryOptions) : factory(provider, factoryOptions);
436
473
  const operations = { ...adapter.operations };
474
+ const key = providerApiKey(provider);
475
+ const scrub = value => typeof value === 'string' && key ? value.split(key).join('[REDACTED]') : value;
476
+ if (operations.discover) {
477
+ const discover = operations.discover;
478
+ operations.discover = async (...args) => {
479
+ const result = await discover(...args);
480
+ assertDiscoveryCredentials(result, key);
481
+ return result;
482
+ };
483
+ }
484
+ operations.mapError = (error, context) => {
485
+ let mapped;
486
+ try { mapped = adapter.operations.mapError(error, context); } catch (failure) { mapped = failure; }
487
+ mapped ||= error;
488
+ const defaults = {};
489
+ for (const field of ['stage', 'category', 'acceptance', 'retryable']) {
490
+ if (mapped?.[field] === undefined) defaults[field] = context?.[field];
491
+ }
492
+ return providerErrorRecord(mapped, {
493
+ ...defaults,
494
+ safeMessage: scrub(String(mapped?.safeMessage || mapped?.message || mapped)),
495
+ providerCode: scrub(mapped?.providerCode ?? mapped?.code)
496
+ });
497
+ };
437
498
  for (const name of ['submit', 'poll']) {
438
499
  if (!operations[name]) continue;
439
500
  const run = operations[name];
440
- operations[name] = async request => {
501
+ operations[name] = async (request, context) => {
441
502
  const modelId = request.model || options.modelId;
442
503
  const report = error => reportModelRateLimit(provider, modelId, {
443
504
  httpStatus: error.httpStatus ?? error.status, category: error.category,
444
- code: error.code, providerCode: error.providerCode, message: error.safeMessage || error.message,
505
+ code: scrub(error.code), providerCode: scrub(error.providerCode), message: scrub(error.safeMessage || error.message),
445
506
  retryAfterMs: error.retryAfterMs,
446
507
  source: options.allowRateLimited ? 'probe' : 'task'
447
508
  }, options.onRateLimit, request.capability);
448
509
  const availability = result => {
449
510
  if (name !== 'submit') return;
450
511
  const event = { ok: result.ok === true, category: result.category,
451
- httpStatus: result.httpStatus ?? result.status, providerCode: result.providerCode || result.code,
452
- note: result.safeMessage || result.message,
512
+ httpStatus: result.httpStatus ?? result.status, providerCode: scrub(result.providerCode || result.code),
513
+ note: scrub(result.safeMessage || result.message),
453
514
  stage: 'submit', source: options.allowRateLimited ? 'probe' : 'task',
454
515
  binding: modelAvailabilityBinding(provider, modelId, capability) };
455
516
  if (!recordModelAvailability(provider, modelId, request.capability, event)) return;
@@ -464,12 +525,12 @@ export function createConfiguredProviderAdapter(provider, options = {}) {
464
525
  }
465
526
  if (name === 'submit' && !options.allowRateLimited && modelRateLimit(provider, modelId)) {
466
527
  const blocked = modelRateLimit(provider, modelId);
467
- throw new ProviderContractError(blocked.until ? '该模型正在限流冷却,请稍后使用' : '该模型额度或预算耗尽,请手动实测成功后再使用', {
528
+ if (!(options.deferCooling && blocked.until)) throw new ProviderContractError(blocked.until ? '该模型正在限流冷却,请稍后使用' : '该模型额度或预算耗尽,请手动实测成功后再使用', {
468
529
  stage: 'validate', category: 'rate_limit', acceptance: 'not_accepted'
469
530
  });
470
531
  }
471
532
  try {
472
- const result = await run(request);
533
+ const result = await run(request, context);
473
534
  if (result.error) report(result.error);
474
535
  availability(result.error || { ok: ['completed', 'accepted'].includes(result.kind) });
475
536
  return result;
@@ -480,5 +541,14 @@ export function createConfiguredProviderAdapter(provider, options = {}) {
480
541
  }
481
542
  };
482
543
  }
483
- return Object.freeze({ ...adapter, operations: Object.freeze(operations) });
544
+ if (network && !hasProviderContext(adapter)) {
545
+ for (const name of Object.keys(operations)) {
546
+ if (name === 'mapError') continue;
547
+ const operation = operations[name];
548
+ operations[name] = (...args) => network.run(() => operation(...args), { ...args[1], operation: name });
549
+ }
550
+ }
551
+ return Object.freeze({ ...adapter, ...(requirement ? { networkRequirement: requirement } : {}), operations: Object.freeze(operations),
552
+ ...(options.deferCooling ? { schedulingReadyAt: modelId => Date.parse(modelRateLimit(provider, modelId)?.until || '') || 0 } : {}),
553
+ ...(network && !hasProviderContext(adapter) && adapter.prepareInput ? { prepareInput: (...args) => network.run(() => adapter.prepareInput(...args), { ...args[1], operation: 'prepareInput' }) } : {}) });
484
554
  }
@@ -5,11 +5,15 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { CAPABILITIES } from './capability.js';
7
7
  import { modelPool, functionModelPool, functionPolicy, orderedModels, providerModels, parseModelRef, resolveModelRef, resolvePoolModel, selectionReasonForModel } from './models.js';
8
- import { dashscopeApiBase, selectMediaProtocol, providerForImageModel, providerForMusicModel, providerMediaBaseUrl, isConfiguredProvider } from './provider-protocol.js';
8
+ import { selectMediaProtocol, providerForImageModel, providerForMusicModel, providerMediaBaseUrl, isConfiguredProvider, providerProtocolDescriptor } from './provider-protocol.js';
9
9
  import { redactProviderMessage } from './provider-contract.js';
10
10
  import { modelRateLimit } from './provider-health.js';
11
11
  import { normalizeVisionInputLimits } from './vision-image-input.js';
12
12
  import { modelDirectorySummary, modelUnavailableReason, validateModelDirectory } from './model-directory.js';
13
+ import { checkedComfyWorkflow, comfyWorkflowDigest } from './comfyui-workflow.js';
14
+ import { assertPluginDependencyMatches } from './plugin-contract.js';
15
+ import { validateProviderCredential, assertProviderCredential, providerCredentialStatus, createProviderSecretScrubber } from './provider-credentials.js';
16
+ import { decodeProviderConfig } from './provider-config-format.js';
13
17
 
14
18
  export class ProviderCatalogError extends Error {
15
19
  constructor(code, message) {
@@ -44,6 +48,7 @@ export function loadProviderCatalog(configPath) {
44
48
  let parsed;
45
49
  try { parsed = JSON.parse(source); }
46
50
  catch (_) { fail('IRIS_PROVIDER_CONFIG_INVALID', 'provider-config 不是有效 JSON'); }
51
+ parsed = decodeProviderConfig(parsed);
47
52
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)
48
53
  || !Array.isArray(parsed.providers)
49
54
  || (parsed.assignments !== undefined && (!parsed.assignments || typeof parsed.assignments !== 'object'
@@ -51,7 +56,11 @@ export function loadProviderCatalog(configPath) {
51
56
  fail('IRIS_PROVIDER_CONFIG_INVALID', 'provider-config 结构无效');
52
57
  }
53
58
  for (const [capability, assignment] of Object.entries(parsed.assignments || {})) functionPolicy(assignment, capability);
54
- for (const provider of parsed.providers) validateModelDirectory(provider.modelDirectory);
59
+ for (const provider of parsed.providers) {
60
+ validateProviderCredential(provider);
61
+ validateModelDirectory(provider.modelDirectory);
62
+ for (const model of provider.models || []) if (model?.comfyWorkflow !== undefined) checkedComfyWorkflow(model.comfyWorkflow);
63
+ }
55
64
  return parsed;
56
65
  }
57
66
 
@@ -62,11 +71,16 @@ const CANDIDATE_CAP_LABEL = Object.freeze({
62
71
  });
63
72
 
64
73
  /** 与 DSH 共用功能策略;CLI 不附加宿主资源。 */
65
- function candidatesFromCatalog(catalog, requestedModelRef, capability) {
74
+ function candidatesFromCatalog(catalog, requestedModelRef, capability, registry, { deferCooling = false } = {}) {
66
75
  const label = CANDIDATE_CAP_LABEL[capability];
67
76
  if (!label) fail('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', '当前 Headless 候选链只开放图片、视频、语音与转写能力');
77
+ const requestedProvider = parseModelRef(String(requestedModelRef || '').trim())?.providerId;
78
+ if (requestedProvider) {
79
+ const provider = catalog.providers.find(item => item.id === requestedProvider && item.enabled !== false);
80
+ if (provider) assertProviderCredential(provider);
81
+ }
68
82
  const providers = catalog.providers.filter(isConfiguredProvider);
69
- const configuredPool = functionModelPool(providers, capability, catalog.assignments?.[capability]);
83
+ const configuredPool = functionModelPool(providers, capability, catalog.assignments?.[capability], { registry });
70
84
  const pool = orderedModels(configuredPool, capability, catalog.assignments?.[capability], { includeRateLimited: true, includeUnavailable: true });
71
85
  const requested = String(requestedModelRef || '').trim();
72
86
  if (requested) {
@@ -76,7 +90,7 @@ function candidatesFromCatalog(catalog, requestedModelRef, capability) {
76
90
  if (!exact) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `指定模型未分配给${label}功能、未启用或没有所需能力`);
77
91
  const provider = providers.find((item) => item.id === exact.providerId);
78
92
  if (exact.unavailableCapabilities?.includes(capability)) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', modelUnavailableReason());
79
- if (exact.rateLimited) fail('IRIS_PROVIDER_MODEL_RATE_LIMITED', modelRateLimit(provider, exact.id)?.until
93
+ if (exact.rateLimited && !(deferCooling && modelRateLimit(provider, exact.id)?.until)) fail('IRIS_PROVIDER_MODEL_RATE_LIMITED', modelRateLimit(provider, exact.id)?.until
80
94
  ? '指定模型正在限流冷却,请稍后使用或手动实测'
81
95
  : '指定模型额度或预算耗尽,请手动实测成功后再使用');
82
96
  return Object.freeze([Object.freeze({
@@ -85,8 +99,14 @@ function candidatesFromCatalog(catalog, requestedModelRef, capability) {
85
99
  })]);
86
100
  }
87
101
  const assignment = catalog.assignments?.[capability];
88
- const ordered = pool.filter(model => !model.rateLimited && !model.unavailableCapabilities?.includes(capability));
89
- if (!ordered.length) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `请检查${label}功能的启用方式、已确认模型、连接和协议;当前没有可用候选`);
102
+ const ordered = pool.filter(model => (!model.rateLimited || deferCooling && modelRateLimit(providers.find(provider => provider.id === model.providerId), model.id)?.until)
103
+ && !model.unavailableCapabilities?.includes(capability));
104
+ if (!ordered.length) {
105
+ const missing = catalog.providers.find(provider => provider.enabled !== false && provider.auth !== 'none' && provider.apiKeyEnv
106
+ && providerCredentialStatus(provider) !== 'ready' && providerModels(provider).some(model => model.capabilities.includes(capability)));
107
+ if (missing) assertProviderCredential(missing);
108
+ fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `请检查${label}功能的启用方式、已确认模型、连接和协议;当前没有可用候选`);
109
+ }
90
110
  return Object.freeze(ordered.map((item) => Object.freeze({
91
111
  provider: providers.find((provider) => provider.id === item.providerId),
92
112
  model: item.id,
@@ -95,37 +115,38 @@ function candidatesFromCatalog(catalog, requestedModelRef, capability) {
95
115
  })));
96
116
  }
97
117
 
98
- export function imageCandidatesFromCatalog(catalog, requestedModelRef = '') {
99
- return Object.freeze(candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.IMAGE).map(route =>
100
- Object.freeze({ ...route, provider: providerForImageModel(route.provider, route.model) })));
118
+ export function imageCandidatesFromCatalog(catalog, requestedModelRef = '', registry, options) {
119
+ return Object.freeze(candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.IMAGE, registry, options).map(route =>
120
+ Object.freeze({ ...route, provider: providerForImageModel(route.provider, route.model, registry) })));
101
121
  }
102
122
 
103
- export function videoCandidatesFromCatalog(catalog, requestedModelRef = '') {
104
- return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.VIDEO);
123
+ export function videoCandidatesFromCatalog(catalog, requestedModelRef = '', registry, options) {
124
+ return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.VIDEO, registry, options);
105
125
  }
106
126
 
107
- export function ttsCandidatesFromCatalog(catalog, requestedModelRef = '') {
108
- return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TTS);
127
+ export function ttsCandidatesFromCatalog(catalog, requestedModelRef = '', registry, options) {
128
+ return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TTS, registry, options);
109
129
  }
110
130
 
111
- export function musicCandidatesFromCatalog(catalog, requestedModelRef = '') {
112
- return Object.freeze(candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.MUSIC).map(route =>
113
- Object.freeze({ ...route, provider: providerForMusicModel(route.provider, route.model) })));
131
+ export function musicCandidatesFromCatalog(catalog, requestedModelRef = '', registry) {
132
+ return Object.freeze(candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.MUSIC, registry).map(route =>
133
+ Object.freeze({ ...route, provider: providerForMusicModel(route.provider, route.model, registry) })));
114
134
  }
115
135
 
116
- export function transcribeCandidatesFromCatalog(catalog, requestedModelRef = '') {
117
- return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TRANSCRIBE);
136
+ export function transcribeCandidatesFromCatalog(catalog, requestedModelRef = '', registry, options) {
137
+ return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TRANSCRIBE, registry, options);
118
138
  }
119
139
 
120
- export function visionCandidatesFromCatalog(catalog, requestedModelRef = '') {
121
- return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.VISION);
140
+ export function visionCandidatesFromCatalog(catalog, requestedModelRef = '', registry) {
141
+ return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.VISION, registry);
122
142
  }
123
143
 
124
144
  /** 只读配置投影;不构造 Adapter、不发现模型、不暴露端点或凭据。 */
125
145
  export function providerCatalogSnapshot(catalog) {
146
+ const scrub = createProviderSecretScrubber(catalog.providers);
126
147
  const safeLabel = (value) => value
127
- ? redactProviderMessage(String(value).replace(/https?:\/\/[^\s<>"']+/gi, '[URL]')) : '';
128
- return {
148
+ ? redactProviderMessage(String(scrub(value)).replace(/https?:\/\/[^\s<>"']+/gi, '[URL]')) : '';
149
+ return scrub({
129
150
  schemaVersion: 1,
130
151
  providers: catalog.providers.filter((provider) => provider && typeof provider === 'object').map((provider) => {
131
152
  const models = providerModels(provider);
@@ -140,16 +161,16 @@ export function providerCatalogSnapshot(catalog) {
140
161
  modelCount: models.length, directory: modelDirectorySummary(provider)
141
162
  };
142
163
  })
143
- };
164
+ });
144
165
  }
145
166
 
146
- export function catalogCapabilitySnapshot(catalog) {
167
+ export function catalogCapabilitySnapshot(catalog, registry) {
147
168
  return {
148
169
  schemaVersion: 1,
149
170
  source: 'configuration',
150
171
  capabilities: Object.values(CAPABILITIES).map((capability) => {
151
172
  const policy = functionPolicy(catalog.assignments?.[capability], capability);
152
- const pool = functionModelPool(catalog.providers.filter(isConfiguredProvider), capability, catalog.assignments?.[capability]);
173
+ const pool = functionModelPool(catalog.providers.filter(isConfiguredProvider), capability, catalog.assignments?.[capability], { registry });
153
174
  const candidates = orderedModels(pool, capability, catalog.assignments?.[capability]).map((model) => model.ref);
154
175
  return { capability, mode: policy.mode, candidates, ...(candidates.length ? {} : { gap: 'no_configured_model' }) };
155
176
  })
@@ -157,21 +178,26 @@ export function catalogCapabilitySnapshot(catalog) {
157
178
  }
158
179
 
159
180
  /** 非敏感摘要把 Task 绑定到提交时实际使用的媒体端点与协议。 */
160
- export function providerTaskBinding(provider) {
181
+ export function providerTaskBinding(provider, registry) {
161
182
  const baseUrl = providerMediaBaseUrl(provider);
162
183
  const protocol = selectMediaProtocol(provider).mediaProtocol;
163
- let endpoint;
184
+ let endpoint, descriptor;
164
185
  try {
165
186
  const url = new URL(baseUrl);
166
187
  if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('unsafe endpoint');
167
- if (!['dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images', 'gemini-web-music'].includes(protocol)) throw new Error('unknown protocol');
168
- endpoint = protocol === 'dashscope' ? dashscopeApiBase(baseUrl) : url.href.replace(/\/+$/, '');
188
+ descriptor = providerProtocolDescriptor(protocol, registry);
189
+ if (!descriptor) throw new Error('unknown protocol');
190
+ endpoint = descriptor.normalizeEndpoint(baseUrl);
191
+ if (typeof endpoint !== 'string' || !endpoint) throw new Error('invalid normalized endpoint');
169
192
  } catch (_) { fail('IRIS_PROVIDER_CONFIG_INVALID', 'Provider 媒体端点或协议无效'); }
170
- return 'sha256:' + crypto.createHash('sha256').update(JSON.stringify([1, protocol, endpoint])).digest('hex');
193
+ const identity = protocol === 'comfyui' ? [2, protocol, endpoint, comfyWorkflowDigest(provider.comfyWorkflow)] : [1, protocol, endpoint];
194
+ // Only the new unified plugin contract opts in. Legacy protocols retain their exact historical binding.
195
+ if (descriptor.dependency) identity.push(descriptor.dependency);
196
+ return 'sha256:' + crypto.createHash('sha256').update(JSON.stringify(identity)).digest('hex');
171
197
  }
172
198
 
173
199
  /** 恢复只使用 Task 已保存的 Provider/模型;忽略当前 assignment 与 failover 顺序。 */
174
- export function providerForTaskFromCatalog(catalog, task) {
200
+ export function providerForTaskFromCatalog(catalog, task, registry) {
175
201
  const capability = CAPABILITIES[
176
202
  task?.capability === 'video' ? 'VIDEO'
177
203
  : task?.capability === 'image' ? 'IMAGE'
@@ -186,11 +212,13 @@ export function providerForTaskFromCatalog(catalog, task) {
186
212
  const matches = catalog.providers.filter((item) => item && item.id === task.providerId);
187
213
  if (matches.length > 1) fail('IRIS_PROVIDER_CONFIG_AMBIGUOUS', 'provider-config 存在重复 Provider ID');
188
214
  const configured = matches[0];
189
- if (!configured || configured.enabled === false || (configured.auth !== 'none' && (typeof configured.apiKey !== 'string' || !configured.apiKey.trim()))) fail('IRIS_PROVIDER_TASK_PROVIDER_UNAVAILABLE', 'Task 原 Provider 已移除、停用或缺少凭据');
190
- const provider = task.capability === 'image' ? providerForImageModel(configured, parsed.modelId)
191
- : task.capability === 'music' ? providerForMusicModel(configured, parsed.modelId) : configured;
215
+ if (configured && configured.enabled !== false) assertProviderCredential(configured);
216
+ if (!configured || configured.enabled === false || (configured.auth !== 'none' && providerCredentialStatus(configured) !== 'ready')) fail('IRIS_PROVIDER_TASK_PROVIDER_UNAVAILABLE', 'Task 原 Provider 已移除、停用或缺少凭据');
217
+ const provider = task.capability === 'image' ? providerForImageModel(configured, parsed.modelId, registry)
218
+ : task.capability === 'music' ? providerForMusicModel(configured, parsed.modelId, registry) : configured;
219
+ assertPluginDependencyMatches(task.pluginDependency, providerProtocolDescriptor(selectMediaProtocol(provider).mediaProtocol, registry)?.dependency);
192
220
  if (!task.providerBinding) fail('IRIS_PROVIDER_TASK_BINDING_MISSING', '旧 Task 缺少提交端点绑定;仅支持只读查看,不猜测恢复配置');
193
- if (providerTaskBinding(provider) !== task.providerBinding) fail('IRIS_PROVIDER_TASK_BINDING_MISMATCH', 'Task 提交端点或协议已改变;拒绝向新端点观察原任务');
221
+ if (providerTaskBinding(provider, registry) !== task.providerBinding) fail('IRIS_PROVIDER_TASK_BINDING_MISMATCH', 'Task 提交端点、协议或插件依赖已改变;请恢复原配置和插件版本,不会重新提交');
194
222
  const model = modelPool([provider]).find((item) => item.providerId === parsed.providerId && item.id === parsed.modelId && item.capabilities.includes(capability));
195
223
  if (!model) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `Task 原模型已移除或不再具备${label}能力`);
196
224
  return provider;
@@ -0,0 +1,51 @@
1
+ // Network-enabled catalogs use an outer envelope which older loaders reject.
2
+ import { CATALOG_NETWORK, checkedNetworkRequirement, sameNetworkRequirement } from './network-policy.js';
3
+
4
+ const fail = message => { throw Object.assign(new Error(message), { code: 'IRIS_PROVIDER_CONFIG_INVALID' }); };
5
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
6
+
7
+ function markedCatalog(catalog, requirement) {
8
+ if (!object(catalog) || !Array.isArray(catalog.providers) || catalog.providers.some(provider => !object(provider))) {
9
+ fail('Provider 配置内容无效');
10
+ }
11
+ const ids = new Set();
12
+ for (const provider of catalog.providers) {
13
+ if (typeof provider.id !== 'string' || !provider.id.trim() || provider.id.length > 256 || /[\x00-\x1f\x7f]/.test(provider.id) || ids.has(provider.id)) fail('网络目录需要明确、唯一且不含控制字符的供应商身份');
14
+ ids.add(provider.id);
15
+ }
16
+ const identity = Object.freeze(checkedNetworkRequirement(requirement));
17
+ return { ...catalog, [CATALOG_NETWORK]: identity,
18
+ providers: catalog.providers.map(provider => ({ ...provider, [CATALOG_NETWORK]: identity })) };
19
+ }
20
+
21
+ export function decodeProviderConfig(parsed) {
22
+ if (object(parsed) && Object.hasOwn(parsed, 'providers') && Object.hasOwn(parsed, 'networkRequirement')) fail('网络目录不能与旧根 providers 字段混用,拒绝忽略网络要求');
23
+ if (!object(parsed) || Object.hasOwn(parsed, 'providers')) return parsed;
24
+ if (parsed.schemaVersion !== 1 || Object.keys(parsed).length !== 3
25
+ || !Object.hasOwn(parsed, 'networkRequirement') || !Object.hasOwn(parsed, 'catalog')) {
26
+ fail('Provider 网络配置包装版本或字段无效;请使用兼容版本,原文件不会自动迁移');
27
+ }
28
+ return markedCatalog(parsed.catalog, parsed.networkRequirement);
29
+ }
30
+
31
+ export function encodeProviderConfig(catalog) {
32
+ const requirement = catalog?.[CATALOG_NETWORK];
33
+ if (requirement === undefined) {
34
+ if (catalog?.providers?.some(provider => provider?.[CATALOG_NETWORK] !== undefined)) fail('Provider 网络配置身份丢失,拒绝写回旧格式');
35
+ return catalog;
36
+ }
37
+ const checked = checkedNetworkRequirement(requirement);
38
+ for (const provider of catalog.providers) {
39
+ const identity = provider?.[CATALOG_NETWORK];
40
+ if (identity !== undefined && !sameNetworkRequirement(checked, identity)) fail('Provider 与目录的网络配置身份不一致');
41
+ }
42
+ const { [CATALOG_NETWORK]: _identity, ...contents } = catalog;
43
+ return { schemaVersion: 1, networkRequirement: checked, catalog: contents };
44
+ }
45
+
46
+ /** Return a new marked runtime catalog; callers explicitly write a new file. */
47
+ export function wrapProviderCatalog(catalog, requirement) {
48
+ const original = encodeProviderConfig(catalog);
49
+ const contents = catalog?.[CATALOG_NETWORK] === undefined ? original : original.catalog;
50
+ return markedCatalog(JSON.parse(JSON.stringify(contents)), requirement);
51
+ }