@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
@@ -13,6 +13,8 @@ import { acceptOperation, inspectExecutionRoot, operationId, readOperation, read
13
13
  import { reservePluginOperation } from './plugin-operations.js';
14
14
  import { invokeReservedPlugin } from './plugin-execution.js';
15
15
  import { acceptPluginOperation, pluginInputDigest, pluginSourceArtifacts, reservePluginArtifact } from './plugin-operation-store.js';
16
+ import { readFlowRun } from './flow-store.js';
17
+ import { findFlowTask } from './core-tasks.js';
16
18
 
17
19
  const activeState = record => ['queued', 'running'].includes(record.state);
18
20
  const busy = () => operationFail('IRIS_CORE_DATA_ROOT_BUSY', '当前写者正在执行,请用原 Key 稍后明确重试');
@@ -28,17 +30,20 @@ export function recoverExecutionLease(root, options) {
28
30
  }
29
31
 
30
32
  /** One execution owner, one finite continue per accepted Operation. Construction performs no I/O. */
31
- export function createOperationRunner({ dataRoot, application: suppliedApplication, resolveImageCandidates, resolveAudioCandidates, resolveTaskAdapter, onExecutionError,
33
+ export function createOperationRunner({ dataRoot, application: suppliedApplication, resolveImageCandidates, resolveAudioCandidates, resolveVideoCandidates, resolveTaskAdapter, onExecutionError,
32
34
  pluginRegistry, allowedOperations = [] } = {}) {
33
35
  const root = normalizeCoreOptions({ dataRoot }).dataRoot;
34
- const application = suppliedApplication || createApplicationRuntime({ dataRoot: root, resolveImageCandidates, resolveAudioCandidates, resolveTaskAdapter });
35
- const ports = { resolveImageCandidates, resolveAudioCandidates, resolveTaskAdapter };
36
+ const application = suppliedApplication || createApplicationRuntime({ dataRoot: root, resolveImageCandidates, resolveAudioCandidates, resolveVideoCandidates, resolveTaskAdapter });
37
+ const ports = { resolveImageCandidates, resolveAudioCandidates, resolveVideoCandidates, resolveTaskAdapter };
36
38
  if (onExecutionError !== undefined && typeof onExecutionError !== 'function') throw new TypeError('执行错误报告器须为函数');
37
- const queue = [];
38
- let state = 'new', physicalRoot, directory, ownership, lease, ownerInstanceId, active, draining, disposal, failure;
39
+ const queue = [], active = new Map(), finalizing = new Map();
40
+ const scheduled = Boolean(application.scheduler);
41
+ const transaction = (action, options) => application.transaction ? application.transaction(action, options) : application.run('writer', action);
42
+ let state = 'new', physicalRoot, directory, ownership, lease, ownerInstanceId, draining, disposal, failure, wake;
39
43
 
40
44
  function snapshot() {
41
- return Object.freeze({ state, queued: queue.length, active: active?.id || null,
45
+ return Object.freeze({ state, queued: queue.length, active: active.keys().next().value || null,
46
+ ...(scheduled ? { activeOperations: [...active.keys()] } : {}),
42
47
  executionEnabled: state === 'open', failed: Boolean(failure) });
43
48
  }
44
49
  function requireOwner() {
@@ -53,20 +58,28 @@ export function createOperationRunner({ dataRoot, application: suppliedApplicati
53
58
  }
54
59
  function failRunner(error) {
55
60
  failure ||= error; state = 'failed';
56
- active?.controller.abort();
61
+ for (const job of active.values()) job.controller.abort();
57
62
  }
58
- async function finishIfActive(id, terminal) {
59
- return application.run('writer', runtime => runtime.run('execute', ({ dataRoot }) => {
63
+ function finishIfActive(id, terminal, failureCode) {
64
+ if (finalizing.has(id)) return finalizing.get(id);
65
+ const completion = transaction(runtime => runtime.run('execute', ({ dataRoot }) => {
60
66
  requireOwner();
61
67
  const record = readOperation(dataRoot, id);
62
68
  if (!record || !activeState(record)) return record;
63
69
  if (record.ownerInstanceId !== ownerInstanceId) operationFail('IRIS_EXECUTION_OWNERSHIP_LOST', 'Operation 不属于本次执行实例');
64
- return transitionOperation(dataRoot, { id, expectedRevision: record.revision, state: terminal });
65
- }));
70
+ return transitionOperation(dataRoot, { id, expectedRevision: record.revision, state: terminal,
71
+ ...(failureCode ? { failureCode } : {}) });
72
+ }), { finalize: true }).finally(() => { finalizing.delete(id); });
73
+ finalizing.set(id, completion);
74
+ return completion;
66
75
  }
67
76
  async function perform(job) {
77
+ let admitted = false;
68
78
  try {
69
- await application.run('writer', runtime => runtime.run('execute', async ({ dataRoot }) => {
79
+ const saved = readOperation(physicalRoot, job.id);
80
+ job.runId = saved?.runId;
81
+ const execute = saved?.action === 'flow.continue' && application.runMedia ? application.runMedia : (action, options) => application.run('writer', action, options);
82
+ await execute(runtime => { admitted = true; return runtime.run('execute', async ({ dataRoot }) => {
70
83
  requireOwner();
71
84
  let record = readOperation(dataRoot, job.id);
72
85
  if (!record || !activeState(record)) return;
@@ -107,28 +120,39 @@ export function createOperationRunner({ dataRoot, application: suppliedApplicati
107
120
  state: job.stopReason || (executionError ? 'failed' : 'succeeded'),
108
121
  ...(executionError && !job.stopReason ? { failureCode: 'execution_failed' } : {}) });
109
122
  if (executionError && !job.stopReason) onExecutionError?.(executionError, job.id);
110
- }), { signal: job.controller.signal });
123
+ }); }, { signal: job.controller.signal, ...(saved?.runId ? { key: `run:${saved.runId}` } : {}) });
111
124
  } catch (error) {
112
125
  if (job.stopReason && error.name === 'AbortError' && !application.snapshot().cleanupFailed) {
113
126
  await finishIfActive(job.id, job.stopReason);
127
+ } else if (scheduled && !admitted && !application.snapshot().cleanupFailed
128
+ && ['IRIS_SCHEDULER_QUEUE_TIMEOUT', 'IRIS_SCHEDULER_QUEUE_FULL'].includes(error.code)) {
129
+ await finishIfActive(job.id, job.stopReason || 'failed', job.stopReason ? undefined : 'execution_failed');
130
+ if (!job.stopReason) onExecutionError?.(error, job.id);
114
131
  } else throw error;
115
132
  }
116
133
  }
117
134
  function pump() {
118
- if (draining || state !== 'open') return;
135
+ if (draining) { wake?.(); return; }
136
+ if (state !== 'open') return;
137
+ let blocked = false;
119
138
  draining = (async () => {
120
- while (state === 'open' && queue.length) {
121
- active = queue.shift();
122
- active.done = perform(active);
123
- try { await active.done; }
124
- catch (error) {
125
- if (error.code !== 'IRIS_CORE_DATA_ROOT_BUSY') throw error;
126
- queue.unshift(active); active = null; break; // No timer or lease stealing; explicit retry wakes this queue.
139
+ while ((state === 'open' && queue.length && !blocked) || active.size) {
140
+ while (state === 'open' && queue.length && !blocked && active.size < (scheduled ? MAX_ACTIVE_OPERATIONS : 1)) {
141
+ const job = queue.shift(); active.set(job.id, job);
142
+ job.done = perform(job).catch(error => {
143
+ if (error.code !== 'IRIS_CORE_DATA_ROOT_BUSY') throw error;
144
+ queue.unshift(job); blocked = true; // Explicit retry only; no lease stealing or polling timer.
145
+ }).finally(() => { active.delete(job.id); });
146
+ }
147
+ if (active.size) {
148
+ const added = new Promise(resolve => { wake = resolve; });
149
+ await Promise.race([...active.values()].map(job => job.done).concat(added));
150
+ wake = null;
127
151
  }
128
- active = null;
129
152
  }
130
153
  })();
131
- void draining.then(() => { draining = null; }, error => { draining = null; failRunner(error); });
154
+ void draining.then(() => { draining = null; if (state === 'open' && queue.length && !blocked) queueMicrotask(pump); },
155
+ error => { draining = null; failRunner(error); });
132
156
  }
133
157
  async function start() {
134
158
  if (state !== 'new') operationFail('IRIS_EXECUTION_LIFECYCLE', '执行服务只能显式启动一次');
@@ -138,7 +162,7 @@ export function createOperationRunner({ dataRoot, application: suppliedApplicati
138
162
  lease = createCoreRuntime({ dataRoot: directory, mode: 'writer' }); lease.start();
139
163
  ownership = inspectRunnerArea(directory); ownerInstanceId = ownership.owner.instanceId;
140
164
  state = 'open';
141
- await application.run('writer', runtime => runtime.run('execute', ({ dataRoot }) => {
165
+ await transaction(runtime => runtime.run('execute', ({ dataRoot }) => {
142
166
  requireOwner();
143
167
  for (const record of readAllOperations(dataRoot).filter(activeState)) {
144
168
  transitionOperation(dataRoot, { id: record.id, expectedRevision: record.revision, state: 'interrupted' });
@@ -163,19 +187,27 @@ export function createOperationRunner({ dataRoot, application: suppliedApplicati
163
187
  if (previous.state === 'queued' && previous.ownerInstanceId === ownerInstanceId) queueMicrotask(pump);
164
188
  return { contractVersion: 0, operation: projectOperation(previous), idempotentReplay: true };
165
189
  }
166
- if (application.snapshot().activeWrites || application.snapshot().queuedWrites) busy();
190
+ if (!scheduled && (application.snapshot().activeWrites || application.snapshot().queuedWrites)) busy();
167
191
  const reservation = reservePluginOperation(pluginRegistry, input.operationId, input.input, allowedOperations, input.dependency);
168
- let record;
192
+ let record, replay = false;
169
193
  try {
170
- record = await application.run('writer', runtime => runtime.run('execute', ({ dataRoot }) => {
194
+ record = await transaction(runtime => runtime.run('execute', ({ dataRoot }) => {
171
195
  writable(); authorize?.();
172
- if (readAllOperations(dataRoot).filter(activeState).length >= MAX_ACTIVE_OPERATIONS) operationFail('IRIS_OPERATION_QUEUE_FULL', '未结束的 Operation 已达上限');
196
+ const current = readOperation(dataRoot, id, marker);
197
+ if (current) {
198
+ if (current.action !== 'plugin.execute' || current.inputDigest !== pluginInputDigest(input.operationId, input.input, input.dependency, current.sourceArtifactIds)) {
199
+ operationFail('IRIS_OPERATION_KEY_CONFLICT', '该 Key 已绑定另一业务或输入');
200
+ }
201
+ replay = true; return current;
202
+ }
203
+ if (readAllOperations(dataRoot).filter(activeState).length >= MAX_ACTIVE_OPERATIONS - Number(scheduled)) operationFail('IRIS_OPERATION_QUEUE_FULL', '未结束的 Operation 已达上限;调度模式保留一个位置供原任务恢复');
173
204
  return acceptPluginOperation(dataRoot, marker, id, { ...input, input: reservation.input,
174
205
  sourceArtifactIds: pluginSourceArtifacts(reservation.input, reservation.artifactInputs), ownerInstanceId });
175
206
  }));
176
207
  } finally { reservation.release(); }
177
- queue.push({ id, controller: new AbortController(), stopReason: null, authorize }); queueMicrotask(pump);
178
- return { contractVersion: 0, operation: projectOperation(record), idempotentReplay: false };
208
+ if (!replay) queue.push({ id, controller: new AbortController(), stopReason: null, authorize });
209
+ if (!replay || record.state === 'queued' && record.ownerInstanceId === ownerInstanceId) queueMicrotask(pump);
210
+ return { contractVersion: 0, operation: projectOperation(record), idempotentReplay: replay };
179
211
  }
180
212
  if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).length !== 3
181
213
  || !['key', 'runId', 'expectedRunRevision'].every(key => Object.hasOwn(input, key))) {
@@ -190,9 +222,14 @@ export function createOperationRunner({ dataRoot, application: suppliedApplicati
190
222
  if (previous.state === 'queued' && previous.ownerInstanceId === ownerInstanceId) queueMicrotask(pump);
191
223
  return { contractVersion: 0, operation: projectOperation(previous), idempotentReplay: true };
192
224
  }
193
- if (application.snapshot().activeWrites || application.snapshot().queuedWrites) busy();
194
- const accepted = await application.run('writer', runtime => runtime.run('execute', ({ dataRoot }) => {
195
- writable(); return acceptOperation(dataRoot, { ...input, ownerInstanceId });
225
+ if (!scheduled && (application.snapshot().activeWrites || application.snapshot().queuedWrites)) busy();
226
+ const accepted = await transaction(runtime => runtime.run('execute', ({ dataRoot }) => {
227
+ writable();
228
+ if (scheduled && !readOperation(dataRoot, id) && readAllOperations(dataRoot).filter(activeState).length >= MAX_ACTIVE_OPERATIONS - 1
229
+ && !findFlowTask(dataRoot, readFlowRun(dataRoot, input.runId))?.attempts.length) {
230
+ operationFail('IRIS_OPERATION_QUEUE_FULL', '新提交队列已满;保留一个位置用于原任务查询和交付');
231
+ }
232
+ return acceptOperation(dataRoot, { ...input, ownerInstanceId });
196
233
  }));
197
234
  if (!accepted.idempotentReplay) {
198
235
  queue.push({ id: accepted.operation.id, controller: new AbortController(), stopReason: null });
@@ -206,10 +243,14 @@ export function createOperationRunner({ dataRoot, application: suppliedApplicati
206
243
  if (!record) operationFail('IRIS_OPERATION_NOT_FOUND', 'Operation 不存在');
207
244
  if (activeState(record)) {
208
245
  if (record.ownerInstanceId !== ownerInstanceId) operationFail('IRIS_EXECUTION_OWNERSHIP_LOST', '不能停止另一执行实例的操作');
209
- const job = active?.id === id ? active : queue.find(job => job.id === id);
246
+ const job = active.get(id) || queue.find(job => job.id === id);
210
247
  if (job) { job.stopReason = 'canceled'; job.controller.abort(); }
211
- if (active?.id === id) await job.done;
212
- else await finishIfActive(id, 'canceled');
248
+ if (active.has(id)) await job.done;
249
+ else {
250
+ await finishIfActive(id, 'canceled');
251
+ const index = queue.indexOf(job);
252
+ if (index >= 0) queue.splice(index, 1);
253
+ }
213
254
  }
214
255
  const saved = readOperation(physicalRoot, id);
215
256
  if (activeState(saved)) operationFail('IRIS_EXECUTION_STOP_UNSAVED', '停止尚未持久保存,请查询原 Operation');
@@ -222,7 +263,7 @@ export function createOperationRunner({ dataRoot, application: suppliedApplicati
222
263
  if (state === 'new') { state = 'closed'; return disposal = suppliedApplication ? Promise.resolve(snapshot()) : application.dispose().then(snapshot); }
223
264
  if (state === 'closed') return Promise.resolve(snapshot());
224
265
  if (state !== 'failed') state = 'closing';
225
- if (active) { active.stopReason ||= 'interrupted'; active.controller.abort(); }
266
+ for (const job of active.values()) { job.stopReason ||= 'interrupted'; job.controller.abort(); }
226
267
  disposal = (async () => {
227
268
  if (draining) await draining;
228
269
  if (failure) throw failure;
@@ -232,5 +273,9 @@ export function createOperationRunner({ dataRoot, application: suppliedApplicati
232
273
  })();
233
274
  return disposal;
234
275
  }
235
- return Object.freeze({ start, submit, stop, idle, dispose, snapshot });
276
+ return Object.freeze({ start, submit, stop, idle, dispose, snapshot,
277
+ scheduling(id) {
278
+ const job = active.get(id), phase = job?.runId && application.schedulingPhase(`run:${job.runId}`);
279
+ return scheduled && phase ? { phase } : undefined;
280
+ } });
236
281
  }
@@ -34,11 +34,11 @@ function input(value, allowed, required = allowed) {
34
34
  }
35
35
  export function inspectExecutionRoot(root) {
36
36
  const marker = inspectLocalRequestRoot(root, { allowAbsent: true });
37
- if (![1, 2, 3].includes(marker?.schemaVersion)) operationFail('IRIS_OPERATION_ROOT_REQUIRED', 'Operation 需要新的专用执行根,请显式执行 execution init;不转换原根');
37
+ if (![1, 2, 3, 4].includes(marker?.schemaVersion)) operationFail('IRIS_OPERATION_ROOT_REQUIRED', 'Operation 需要新的专用执行根,请显式执行 execution init;不转换原根');
38
38
  return marker;
39
39
  }
40
- export function initializeExecutionRoot(root, { imageGeneration = false, audio = false } = {}) {
41
- const marker = initializeLocalRequestRoot(root, audio ? 3 : imageGeneration ? 2 : 1);
40
+ export function initializeExecutionRoot(root, { imageGeneration = false, audio = false, video = false } = {}) {
41
+ const marker = initializeLocalRequestRoot(root, video ? 4 : audio ? 3 : imageGeneration ? 2 : 1);
42
42
  listCreationRequests(root); readAllOperations(root);
43
43
  return marker;
44
44
  }
@@ -64,7 +64,7 @@ function validate(record, id, marker) {
64
64
  || !UUID.test(record.ownerInstanceId) || !STATES.has(record.state) || typeof record.cancelRequested !== 'boolean'
65
65
  || !iso(record.createdAt) || !iso(record.updatedAt) || record.updatedAt < record.createdAt
66
66
  || !keys(record.binding, ['taskId', 'sourceArtifactId', 'comparisonArtifactId', 'inputDigest'])
67
- || !TASK.test(record.binding.taskId) || !(marker.schemaVersion === 3 && (record.binding.sourceArtifactId === null || ARTIFACT.test(record.binding.sourceArtifactId))
67
+ || !TASK.test(record.binding.taskId) || !([3, 4].includes(marker.schemaVersion) && (record.binding.sourceArtifactId === null || ARTIFACT.test(record.binding.sourceArtifactId))
68
68
  && (record.binding.comparisonArtifactId === null || ARTIFACT.test(record.binding.comparisonArtifactId))
69
69
  || record.binding.sourceArtifactId === null && record.binding.comparisonArtifactId === null && marker.schemaVersion === 2
70
70
  || ARTIFACT.test(record.binding.sourceArtifactId) && ARTIFACT.test(record.binding.comparisonArtifactId))
@@ -76,7 +76,7 @@ function validate(record, id, marker) {
76
76
  || record.state === 'running' && (!hasStarted || record.cancelRequested)
77
77
  || record.state === 'succeeded' && (!hasStarted || record.cancelRequested)
78
78
  || record.cancelRequested !== (record.state === 'canceled')
79
- || record.state === 'failed' && (!FAILURE_CODES.has(record.failureCode) || !hasStarted && record.failureCode !== 'run_changed')
79
+ || record.state === 'failed' && !FAILURE_CODES.has(record.failureCode)
80
80
  || record.state !== 'failed' && Object.hasOwn(record, 'failureCode')) invalid();
81
81
  if (ACTIVE.has(record.state)) {
82
82
  if (Object.hasOwn(record, 'finishedAt') || Object.hasOwn(record, 'result')) invalid();
@@ -172,7 +172,7 @@ export function transitionOperation(root, value) {
172
172
  const allowed = current.state === 'queued' ? ['running', 'canceled', 'interrupted', 'failed']
173
173
  : current.state === 'running' ? ['succeeded', 'failed', 'canceled', 'interrupted'] : [];
174
174
  if (!allowed.includes(value.state)) operationFail('IRIS_OPERATION_TRANSITION_INVALID', '不允许此 Operation 状态转换;结束记录不重新排队');
175
- if (value.state === 'failed' ? !FAILURE_CODES.has(value.failureCode) || current.state === 'queued' && value.failureCode !== 'run_changed'
175
+ if (value.state === 'failed' ? !FAILURE_CODES.has(value.failureCode)
176
176
  : Object.hasOwn(value, 'failureCode')) operationFail('IRIS_OPERATION_INPUT_INVALID', '失败记录只接受有限错误类别,不保存异常原文');
177
177
  const { run, task } = validateBinding(root, current);
178
178
  if (value.state === 'running' && run.revision !== current.expectedRunRevision) operationFail('IRIS_OPERATION_RUN_CHANGED', 'Run revision 已改变,未开始本次调用');
package/lib/plugins.d.ts CHANGED
@@ -1,4 +1,23 @@
1
1
  /** Experimental v0. Trusted local code; no sandbox, discovery, installation or implicit grant. */
2
+ import type { ProviderCapability, ProviderMethod, ProviderOperations, ProviderConnection, ProviderFactoryContext, ProviderErrorRecord } from './provider-context.js';
3
+ export { PROVIDER_FACTORY_CONTEXT_VERSION } from './provider-context.js';
4
+ export type { ProviderCapability, ProviderMethod, ProviderOperations, ProviderConnection, ProviderFactoryContext, ProviderNetwork,
5
+ ProviderErrorRecord, ProviderStage, ProviderAcceptance, ProviderErrorCategory, ProviderOperationContext, ProviderSubmitRequest,
6
+ ProviderSubmissionResult, ProviderRemoteRequest, ProviderPollResult, ProviderCancelResult, ProviderDownloadRequest,
7
+ ProviderDiscoveryRequest, ProviderDiscoveryResult, ProviderArtifact, ProviderImageInput } from './provider-context.js';
8
+ export class ProviderContractError extends Error {
9
+ constructor(message: string, details?: Partial<ProviderErrorRecord>);
10
+ readonly stage: ProviderErrorRecord['stage'];
11
+ readonly category: ProviderErrorRecord['category'];
12
+ readonly acceptance: ProviderErrorRecord['acceptance'];
13
+ readonly retryable: boolean;
14
+ readonly safeMessage: string;
15
+ readonly httpStatus?: number;
16
+ readonly providerCode?: string;
17
+ /** Optional transient transport hint, read by Core health before error persistence. */
18
+ retryAfterMs?: number;
19
+ toJSON(): ProviderErrorRecord;
20
+ }
2
21
  export type JsonValue = null | boolean | number | string | readonly JsonValue[] | { readonly [key: string]: JsonValue };
3
22
  export type PluginKind = 'providers' | 'operations' | 'pages' | 'renderers' | 'settings';
4
23
  export const PLUGIN_CONTRACT_VERSION: 0;
@@ -60,11 +79,9 @@ export interface PluginOperationContext {
60
79
  readonly core: { execute(command: PluginCoreCommand, input: Record<string, unknown>): Promise<unknown> };
61
80
  }
62
81
  export type PluginOperation = (input: Readonly<Record<string, JsonValue>>, context: PluginOperationContext) => JsonValue | Promise<JsonValue>;
63
- export type ProviderCapability = 'image' | 'video' | 'tts' | 'music' | 'transcribe';
64
- export type ProviderMethod = 'discover' | 'submit' | 'poll' | 'cancel' | 'download' | 'mapError';
65
82
  export interface ProviderAdapterInput {
66
83
  id: string; protocol: string; capabilities: ProviderCapability[]; contractVersion?: 0;
67
- operations: Partial<Record<ProviderMethod, (...args: any[]) => unknown>>;
84
+ operations: ProviderOperations;
68
85
  unsupported?: Partial<Record<ProviderMethod, string>>;
69
86
  prepareInput?: (...args: any[]) => unknown;
70
87
  unsupportedPreparation?: string;
@@ -73,14 +90,14 @@ export interface ProviderAdapterInput {
73
90
  export type ProviderAdapter = Readonly<Omit<ProviderAdapterInput, 'contractVersion' | 'capabilities' | 'operations' | 'unsupported'> & {
74
91
  contractVersion: 0;
75
92
  capabilities: readonly ProviderCapability[];
76
- operations: Readonly<Partial<Record<ProviderMethod, (...args: any[]) => unknown>>>;
93
+ operations: Readonly<ProviderOperations>;
77
94
  unsupported: Readonly<Partial<Record<ProviderMethod, { readonly reason: string }>>>;
78
95
  }>;
79
96
  export function defineProviderAdapter(input: ProviderAdapterInput): ProviderAdapter;
80
97
  export function normalizeMediaEndpoint(value: string): string;
81
98
  export interface ProviderImplementation {
82
99
  normalizeEndpoint(value: string): string;
83
- createAdapter(provider: Record<string, any>, options?: Record<string, any>): ProviderAdapter;
100
+ createAdapter(provider: Readonly<ProviderConnection>, context: Readonly<ProviderFactoryContext>): ProviderAdapter;
84
101
  supportsModel?(input: { provider: Record<string, unknown>; modelId: string; capability: string; checkEndpoint?: boolean }): boolean;
85
102
  supportsImageEdit?(modelId: string): boolean;
86
103
  }
@@ -161,7 +178,7 @@ export interface CoreCapabilitySnapshot {
161
178
  };
162
179
  }
163
180
  export interface CoreCapabilityCatalog {
164
- snapshot(options?: { audioEnabled?: boolean; panelContributions?: PluginPanelSelection; allowedOperations?: readonly string[] }): CoreCapabilitySnapshot;
181
+ snapshot(options?: { audioEnabled?: boolean; videoEnabled?: boolean; panelContributions?: PluginPanelSelection; allowedOperations?: readonly string[] }): CoreCapabilitySnapshot;
165
182
  /** Validate an explicit local selection. Does not invoke, persist a grant or enable MCP/Web. */
166
183
  selectOperations(ids?: readonly string[]): readonly (OperationContribution & CatalogContribution)[];
167
184
  }
@@ -219,6 +236,7 @@ export interface PluginWebSession {
219
236
  readonly operations: readonly string[];
220
237
  readonly readOnly: boolean;
221
238
  readonly audio: boolean;
239
+ readonly video: boolean;
222
240
  readonly providerManagement: boolean;
223
241
  readonly pluginExecution: boolean;
224
242
  readonly pluginOperations: readonly PluginDependency[];
@@ -256,6 +274,7 @@ export interface PluginWebApi {
256
274
  run(id: string, options?: PluginWebRequestOptions): Promise<JsonValue>;
257
275
  models(options?: PluginWebRequestOptions): Promise<JsonValue>;
258
276
  audioModels(options?: PluginWebRequestOptions): Promise<JsonValue>;
277
+ videoModels(options?: PluginWebRequestOptions): Promise<JsonValue>;
259
278
  artifact(artifact: PluginArtifact, options?: PluginWebRequestOptions): Promise<Blob>;
260
279
  /** Only settings mounts in an explicitly enabled owner Provider-management session. */
261
280
  readonly providers?: {
package/lib/plugins.js CHANGED
@@ -13,7 +13,8 @@ export { createCoreCapabilityCatalog } from './core-capability-catalog.js';
13
13
 
14
14
  export { definePlugin, PluginContractError, PLUGIN_CONTRACT_VERSION, PLUGIN_KINDS, PLUGIN_CORE_COMMANDS,
15
15
  checkedPluginDependency, comparePluginVersions } from './plugin-contract.js';
16
- export { defineProviderAdapter, normalizeMediaEndpoint } from './provider-extensions.js';
16
+ export { defineProviderAdapter, ProviderContractError, normalizeMediaEndpoint } from './provider-extensions.js';
17
+ export { PROVIDER_FACTORY_CONTEXT_VERSION } from './provider-context.js';
17
18
 
18
19
  export function createCorePluginRegistry({ plugins = [], coreVersion } = {}) {
19
20
  coreVersion ??= JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
@@ -196,7 +196,7 @@ export function providerAdapterSnapshot(adapter) {
196
196
  }
197
197
 
198
198
  /** 提交前的可选上传能力;不创建 Task,也不属于六个生命周期操作。 */
199
- export async function prepareProviderInput(adapter, input = {}) {
199
+ export async function prepareProviderInput(adapter, input = {}, operationContext = {}) {
200
200
  if (typeof adapter?.prepareInput !== 'function') {
201
201
  const error = new ProviderContractError(adapter?.unsupportedPreparation || '协议未实现临时输入上传', {
202
202
  stage: 'upload', category: 'invalid_request', acceptance: 'not_accepted',
@@ -206,7 +206,7 @@ export async function prepareProviderInput(adapter, input = {}) {
206
206
  throw error;
207
207
  }
208
208
  try {
209
- const value = await adapter.prepareInput(input);
209
+ const value = await adapter.prepareInput(input, operationContext);
210
210
  if (typeof value?.url !== 'string' || !value.url.trim()) {
211
211
  throw new ProviderContractError('Provider 输入准备必须返回非空 url', {
212
212
  stage: 'upload', category: 'protocol', acceptance: 'not_accepted'
@@ -16,6 +16,11 @@ import { modelUnavailable, modelUnavailableReason, modelAvailabilityBinding } fr
16
16
  import { prepareVisionImage, visionInputLimits } from './vision-image-input.js';
17
17
  import { createComfyUiProviderAdapter } from './comfyui-adapter.js';
18
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';
19
24
 
20
25
  const AMBIGUOUS_4XX = new Set([408, 409, 425, 499]);
21
26
 
@@ -449,15 +454,33 @@ export function builtinProviderExtension() {
449
454
  }
450
455
 
451
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);
452
467
  const selection = options.protocol
453
468
  ? selectMediaProtocol({ ...provider, mediaProtocol: options.protocol, protocolInferred: false })
454
469
  : selectMediaProtocol(provider);
455
470
  const factory = Object.hasOwn(PROTOCOL_FACTORIES, selection.mediaProtocol) ? PROTOCOL_FACTORIES[selection.mediaProtocol] : null;
456
471
  if (!options.registry && !factory) throw unsupportedProtocolError(selection.mediaProtocol);
457
- const adapter = options.registry ? options.registry.createAdapter(selection.mediaProtocol, provider, options) : factory(provider, options);
472
+ const adapter = options.registry ? options.registry.createAdapter(selection.mediaProtocol, provider, factoryOptions) : factory(provider, factoryOptions);
458
473
  const operations = { ...adapter.operations };
459
474
  const key = providerApiKey(provider);
460
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
+ }
461
484
  operations.mapError = (error, context) => {
462
485
  let mapped;
463
486
  try { mapped = adapter.operations.mapError(error, context); } catch (failure) { mapped = failure; }
@@ -475,7 +498,7 @@ export function createConfiguredProviderAdapter(provider, options = {}) {
475
498
  for (const name of ['submit', 'poll']) {
476
499
  if (!operations[name]) continue;
477
500
  const run = operations[name];
478
- operations[name] = async request => {
501
+ operations[name] = async (request, context) => {
479
502
  const modelId = request.model || options.modelId;
480
503
  const report = error => reportModelRateLimit(provider, modelId, {
481
504
  httpStatus: error.httpStatus ?? error.status, category: error.category,
@@ -502,12 +525,12 @@ export function createConfiguredProviderAdapter(provider, options = {}) {
502
525
  }
503
526
  if (name === 'submit' && !options.allowRateLimited && modelRateLimit(provider, modelId)) {
504
527
  const blocked = modelRateLimit(provider, modelId);
505
- throw new ProviderContractError(blocked.until ? '该模型正在限流冷却,请稍后使用' : '该模型额度或预算耗尽,请手动实测成功后再使用', {
528
+ if (!(options.deferCooling && blocked.until)) throw new ProviderContractError(blocked.until ? '该模型正在限流冷却,请稍后使用' : '该模型额度或预算耗尽,请手动实测成功后再使用', {
506
529
  stage: 'validate', category: 'rate_limit', acceptance: 'not_accepted'
507
530
  });
508
531
  }
509
532
  try {
510
- const result = await run(request);
533
+ const result = await run(request, context);
511
534
  if (result.error) report(result.error);
512
535
  availability(result.error || { ok: ['completed', 'accepted'].includes(result.kind) });
513
536
  return result;
@@ -518,5 +541,14 @@ export function createConfiguredProviderAdapter(provider, options = {}) {
518
541
  }
519
542
  };
520
543
  }
521
- 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' }) } : {}) });
522
554
  }
@@ -12,6 +12,8 @@ import { normalizeVisionInputLimits } from './vision-image-input.js';
12
12
  import { modelDirectorySummary, modelUnavailableReason, validateModelDirectory } from './model-directory.js';
13
13
  import { checkedComfyWorkflow, comfyWorkflowDigest } from './comfyui-workflow.js';
14
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';
15
17
 
16
18
  export class ProviderCatalogError extends Error {
17
19
  constructor(code, message) {
@@ -46,6 +48,7 @@ export function loadProviderCatalog(configPath) {
46
48
  let parsed;
47
49
  try { parsed = JSON.parse(source); }
48
50
  catch (_) { fail('IRIS_PROVIDER_CONFIG_INVALID', 'provider-config 不是有效 JSON'); }
51
+ parsed = decodeProviderConfig(parsed);
49
52
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)
50
53
  || !Array.isArray(parsed.providers)
51
54
  || (parsed.assignments !== undefined && (!parsed.assignments || typeof parsed.assignments !== 'object'
@@ -54,6 +57,7 @@ export function loadProviderCatalog(configPath) {
54
57
  }
55
58
  for (const [capability, assignment] of Object.entries(parsed.assignments || {})) functionPolicy(assignment, capability);
56
59
  for (const provider of parsed.providers) {
60
+ validateProviderCredential(provider);
57
61
  validateModelDirectory(provider.modelDirectory);
58
62
  for (const model of provider.models || []) if (model?.comfyWorkflow !== undefined) checkedComfyWorkflow(model.comfyWorkflow);
59
63
  }
@@ -67,9 +71,14 @@ const CANDIDATE_CAP_LABEL = Object.freeze({
67
71
  });
68
72
 
69
73
  /** 与 DSH 共用功能策略;CLI 不附加宿主资源。 */
70
- function candidatesFromCatalog(catalog, requestedModelRef, capability, registry) {
74
+ function candidatesFromCatalog(catalog, requestedModelRef, capability, registry, { deferCooling = false } = {}) {
71
75
  const label = CANDIDATE_CAP_LABEL[capability];
72
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
+ }
73
82
  const providers = catalog.providers.filter(isConfiguredProvider);
74
83
  const configuredPool = functionModelPool(providers, capability, catalog.assignments?.[capability], { registry });
75
84
  const pool = orderedModels(configuredPool, capability, catalog.assignments?.[capability], { includeRateLimited: true, includeUnavailable: true });
@@ -81,7 +90,7 @@ function candidatesFromCatalog(catalog, requestedModelRef, capability, registry)
81
90
  if (!exact) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `指定模型未分配给${label}功能、未启用或没有所需能力`);
82
91
  const provider = providers.find((item) => item.id === exact.providerId);
83
92
  if (exact.unavailableCapabilities?.includes(capability)) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', modelUnavailableReason());
84
- 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
85
94
  ? '指定模型正在限流冷却,请稍后使用或手动实测'
86
95
  : '指定模型额度或预算耗尽,请手动实测成功后再使用');
87
96
  return Object.freeze([Object.freeze({
@@ -90,8 +99,14 @@ function candidatesFromCatalog(catalog, requestedModelRef, capability, registry)
90
99
  })]);
91
100
  }
92
101
  const assignment = catalog.assignments?.[capability];
93
- const ordered = pool.filter(model => !model.rateLimited && !model.unavailableCapabilities?.includes(capability));
94
- 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
+ }
95
110
  return Object.freeze(ordered.map((item) => Object.freeze({
96
111
  provider: providers.find((provider) => provider.id === item.providerId),
97
112
  model: item.id,
@@ -100,17 +115,17 @@ function candidatesFromCatalog(catalog, requestedModelRef, capability, registry)
100
115
  })));
101
116
  }
102
117
 
103
- export function imageCandidatesFromCatalog(catalog, requestedModelRef = '', registry) {
104
- return Object.freeze(candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.IMAGE, registry).map(route =>
118
+ export function imageCandidatesFromCatalog(catalog, requestedModelRef = '', registry, options) {
119
+ return Object.freeze(candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.IMAGE, registry, options).map(route =>
105
120
  Object.freeze({ ...route, provider: providerForImageModel(route.provider, route.model, registry) })));
106
121
  }
107
122
 
108
- export function videoCandidatesFromCatalog(catalog, requestedModelRef = '', registry) {
109
- return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.VIDEO, registry);
123
+ export function videoCandidatesFromCatalog(catalog, requestedModelRef = '', registry, options) {
124
+ return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.VIDEO, registry, options);
110
125
  }
111
126
 
112
- export function ttsCandidatesFromCatalog(catalog, requestedModelRef = '', registry) {
113
- return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TTS, registry);
127
+ export function ttsCandidatesFromCatalog(catalog, requestedModelRef = '', registry, options) {
128
+ return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TTS, registry, options);
114
129
  }
115
130
 
116
131
  export function musicCandidatesFromCatalog(catalog, requestedModelRef = '', registry) {
@@ -118,8 +133,8 @@ export function musicCandidatesFromCatalog(catalog, requestedModelRef = '', regi
118
133
  Object.freeze({ ...route, provider: providerForMusicModel(route.provider, route.model, registry) })));
119
134
  }
120
135
 
121
- export function transcribeCandidatesFromCatalog(catalog, requestedModelRef = '', registry) {
122
- return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TRANSCRIBE, registry);
136
+ export function transcribeCandidatesFromCatalog(catalog, requestedModelRef = '', registry, options) {
137
+ return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TRANSCRIBE, registry, options);
123
138
  }
124
139
 
125
140
  export function visionCandidatesFromCatalog(catalog, requestedModelRef = '', registry) {
@@ -128,9 +143,10 @@ export function visionCandidatesFromCatalog(catalog, requestedModelRef = '', reg
128
143
 
129
144
  /** 只读配置投影;不构造 Adapter、不发现模型、不暴露端点或凭据。 */
130
145
  export function providerCatalogSnapshot(catalog) {
146
+ const scrub = createProviderSecretScrubber(catalog.providers);
131
147
  const safeLabel = (value) => value
132
- ? redactProviderMessage(String(value).replace(/https?:\/\/[^\s<>"']+/gi, '[URL]')) : '';
133
- return {
148
+ ? redactProviderMessage(String(scrub(value)).replace(/https?:\/\/[^\s<>"']+/gi, '[URL]')) : '';
149
+ return scrub({
134
150
  schemaVersion: 1,
135
151
  providers: catalog.providers.filter((provider) => provider && typeof provider === 'object').map((provider) => {
136
152
  const models = providerModels(provider);
@@ -145,7 +161,7 @@ export function providerCatalogSnapshot(catalog) {
145
161
  modelCount: models.length, directory: modelDirectorySummary(provider)
146
162
  };
147
163
  })
148
- };
164
+ });
149
165
  }
150
166
 
151
167
  export function catalogCapabilitySnapshot(catalog, registry) {
@@ -196,7 +212,8 @@ export function providerForTaskFromCatalog(catalog, task, registry) {
196
212
  const matches = catalog.providers.filter((item) => item && item.id === task.providerId);
197
213
  if (matches.length > 1) fail('IRIS_PROVIDER_CONFIG_AMBIGUOUS', 'provider-config 存在重复 Provider ID');
198
214
  const configured = matches[0];
199
- if (!configured || configured.enabled === false || (configured.auth !== 'none' && (typeof configured.apiKey !== 'string' || !configured.apiKey.trim()))) fail('IRIS_PROVIDER_TASK_PROVIDER_UNAVAILABLE', 'Task 原 Provider 已移除、停用或缺少凭据');
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 已移除、停用或缺少凭据');
200
217
  const provider = task.capability === 'image' ? providerForImageModel(configured, parsed.modelId, registry)
201
218
  : task.capability === 'music' ? providerForMusicModel(configured, parsed.modelId, registry) : configured;
202
219
  assertPluginDependencyMatches(task.pluginDependency, providerProtocolDescriptor(selectMediaProtocol(provider).mediaProtocol, registry)?.dependency);