@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
@@ -6,16 +6,29 @@ import { isKnownCapability } from './capability.js';
6
6
  import { providerModels, modelPool, functionPolicy, savedAssignmentRefs, parseModelRef, HOST_VISION_REF, capabilitiesOfDiscoveredModel, hasConfiguredModelCapabilities, resolvePoolModel } from './models.js';
7
7
  import { atomicWritePrivate, privateSibling, ensurePrivateDir } from './private-storage.js';
8
8
  import { createConfiguredProviderAdapter } from './provider-adapters.js';
9
- import { selectImageProtocol, normalizeImageProtocol, normalizeMusicProtocol, isConfiguredProvider } from './provider-protocol.js';
9
+ import { selectImageProtocol, normalizeImageProtocol, normalizeMusicProtocol, isConfiguredProvider, providerForImageModel, providerApiKey } from './provider-protocol.js';
10
10
  import { invokeProviderOperation } from './provider-adapter.js';
11
11
  import { redactProviderMessage } from './provider-contract.js';
12
12
  import { modelRateLimit, recordModelRateLimit, clearModelRateLimit, clearModelCapabilityHealth, isDefinitiveHealthFailure, recordModelAvailability, resetHealthOnCriticalChange } from './provider-health.js';
13
13
  import { normalizeVisionInputLimits, visionInputLimits } from './vision-image-input.js';
14
14
  import { currentModelDirectory, modelDirectoryBinding, modelAvailabilityBinding } from './model-directory.js';
15
15
  import { refreshModelDirectory } from './model-directory-service.js';
16
+ import { projectProviderSettings, applyProviderSettings, applyFunctionPolicies } from './provider-settings.js';
17
+ import { checkedComfyWorkflow, compileComfyWorkflow, projectComfyWorkflow } from './comfyui-workflow.js';
18
+ import { createComfyUiClient } from './comfyui-client.js';
19
+ import { applyCredentialPatch, assertProviderCredential, resolveProviderCredential, createProviderSecretScrubber } from './provider-credentials.js';
20
+ import { inspectComfyWorkflow } from './comfyui-inspection.js';
21
+ import { encodeProviderConfig } from './provider-config-format.js';
22
+ import { providerNetworkRequirement, sameNetworkRequirement, networkPolicyError } from './network-policy.js';
16
23
 
17
24
  const fail = message => { throw Object.assign(new Error(message), { code: 'IRIS_CONFIG_INPUT_INVALID' }); };
18
25
  const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
26
+ // Owner edits change configuration, while task health and directory caches can
27
+ // advance independently. Saves still load fresh records and use a byte CAS under
28
+ // the file lock, preserving the latest runtime evidence.
29
+ export function settingsRevision(catalog) {
30
+ return hash(JSON.stringify(encodeProviderConfig({ ...catalog, providers: catalog.providers.map(({ health, modelDirectory, ...provider }) => provider) })));
31
+ }
19
32
  const safe = value => redactProviderMessage(String(value || '').replace(/https?:\/\/[^\s<>"']+/gi, '[URL]'));
20
33
  const text = value => { if (typeof value !== 'string' || !value.trim()) fail('ID 必须是非空字符串'); return value.trim(); };
21
34
  const caps = value => {
@@ -24,33 +37,51 @@ const caps = value => {
24
37
  };
25
38
 
26
39
  function assignmentSnapshot(catalog) {
40
+ const scrub = createProviderSecretScrubber(catalog.providers), label = value => safe(scrub(value));
27
41
  const pool = modelPool(catalog.providers, { includeHostVision: true });
28
42
  return Object.fromEntries(Object.entries(catalog.assignments || {}).map(([capability, value]) => {
29
43
  const policy = functionPolicy(value, capability);
30
44
  const refs = policy.models.map(ref => resolvePoolModel(pool, ref, capability));
31
- return [safe(capability), { mode: policy.mode, hostVision: policy.hostVision,
32
- model_refs: refs.filter(Boolean).map(model => safe(model.ref)), invalidRefs: refs.filter(ref => !ref).length }];
45
+ return [label(capability), { mode: policy.mode, hostVision: policy.hostVision,
46
+ model_refs: refs.filter(Boolean).map(model => label(model.ref)), invalidRefs: refs.filter(ref => !ref).length }];
33
47
  }));
34
48
  }
35
49
 
36
- export function configSnapshot(catalog) {
37
- return { ...providerCatalogSnapshot(catalog), models: catalog.providers.flatMap(provider => providerModels(provider).map(model => ({
38
- ref: safe(model.ref), providerId: safe(provider.id), id: safe(model.id), capabilities: model.capabilities,
50
+ export function configSnapshot(catalog, registry) {
51
+ const scrub = createProviderSecretScrubber(catalog.providers), label = value => safe(scrub(value));
52
+ return scrub({ ...providerCatalogSnapshot(catalog), models: catalog.providers.flatMap(provider => providerModels(provider).map(model => ({
53
+ ref: label(model.ref), providerId: label(provider.id), id: label(model.id), capabilities: model.capabilities,
39
54
  ...(model.catalogStatus ? { catalogStatus: model.catalogStatus } : {}),
40
55
  ...(model.unavailableCapabilities ? { unavailableCapabilities: model.unavailableCapabilities } : {}),
41
56
  ...(model.imageProtocol !== undefined ? { imageProtocol: model.imageProtocol } : {}),
42
57
  ...(model.musicProtocol !== undefined ? { musicProtocol: model.musicProtocol } : {}),
43
58
  ...(model.visionInput !== undefined ? { visionInput: model.visionInput } : {}),
59
+ ...(provider.models?.find(entry => entry?.id === model.id)?.comfyWorkflow ? {
60
+ comfyWorkflow: projectComfyWorkflow(provider.models.find(entry => entry?.id === model.id).comfyWorkflow) } : {}),
44
61
  ...(model.capabilities.includes('vision') ? { visionInputEffective: visionInputLimits(provider, model.id) } : {}),
45
- ...(model.capabilities.includes('image-gen') ? { imageRouting: selectImageProtocol(provider, model.id) } : {}),
46
- verified: Object.fromEntries(Object.entries(model.verified || {}).map(([capability, value]) => [capability, { ok: value.ok === true, at: safe(value.at), category: safe(value.category) }])),
62
+ ...(model.capabilities.includes('image-gen') ? { imageRouting: selectImageProtocol(provider, model.id, registry) } : {}),
63
+ verified: Object.fromEntries(Object.entries(model.verified || {}).map(([capability, value]) => [capability, { ok: value.ok === true, at: label(value.at), category: label(value.category) }])),
47
64
  ...(model.rateLimited ? { rateLimited: true, rateLimitedAt: modelRateLimit(provider, model.id).at,
48
65
  ...(modelRateLimit(provider, model.id).until ? { retryAt: modelRateLimit(provider, model.id).until }
49
66
  : { reason: modelRateLimit(provider, model.id).reason }) } : {})
50
- }))), assignments: assignmentSnapshot(catalog), routing: catalogCapabilitySnapshot(catalog) };
67
+ }))), assignments: assignmentSnapshot(catalog), routing: catalogCapabilitySnapshot(catalog, registry) });
51
68
  }
52
69
 
53
- export async function executeConfigCommand(configPath, command, input = {}, { signal, probe } = {}) {
70
+ export async function executeConfigCommand(configPath, command, input = {}, { signal, probe, registry, resolveProviderNetwork } = {}) {
71
+ if (resolveProviderNetwork !== undefined && typeof resolveProviderNetwork !== 'function') throw new TypeError('resolveProviderNetwork 必须是函数');
72
+ const networkFor = (provider, modelRef) => {
73
+ const requirement = providerNetworkRequirement(provider);
74
+ const network = resolveProviderNetwork?.({ providerId: provider.id, modelRef, provider, networkRequirement: requirement });
75
+ if (requirement && (!network || !sameNetworkRequirement(requirement, network.networkRequirement) || network.providerId !== provider.id)) throw networkPolicyError('IRIS_NETWORK_CONTEXT_REQUIRED', '此目录需要匹配的显式网络配置,未装配的检查不会默认直连');
76
+ return network;
77
+ };
78
+ const adapterFor = provider => {
79
+ const network = networkFor(provider);
80
+ return createConfiguredProviderAdapter(provider, { registry, ...(network !== undefined ? { network } : {}) });
81
+ };
82
+ const assertRemovable = provider => {
83
+ if (providerNetworkRequirement(provider) && networkFor(provider)?.poolId !== undefined) throw networkPolicyError('IRIS_NETWORK_CONFIG_IN_USE', '此供应商仍有出口绑定;请先明确解除绑定,再删除连接');
84
+ };
54
85
  if (command === 'init') {
55
86
  if (!path.isAbsolute(configPath)) fail('provider-config 必须是绝对路径');
56
87
  ensurePrivateDir(path.dirname(configPath));
@@ -59,8 +90,81 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
59
90
  }
60
91
  const catalog = loadProviderCatalog(configPath);
61
92
  const before = fs.readFileSync(configPath);
62
- if (JSON.stringify(JSON.parse(before)) !== JSON.stringify(catalog)) throw Object.assign(new Error('配置在读取期间发生变化,请重试'), { code: 'IRIS_CONFIG_CHANGED' });
63
- if (command === 'show') return configSnapshot(catalog);
93
+ if (JSON.stringify(JSON.parse(before)) !== JSON.stringify(encodeProviderConfig(catalog))) throw Object.assign(new Error('配置在读取期间发生变化,请重试'), { code: 'IRIS_CONFIG_CHANGED' });
94
+ if (command === 'settings.show') return projectProviderSettings(catalog, settingsRevision(catalog), registry);
95
+ if (['settings.policies', 'settings.enabled', 'settings.remove'].includes(command)) {
96
+ const fields = command === 'settings.policies' ? ['expectedRevision', 'assignments'] : command === 'settings.enabled'
97
+ ? ['expectedRevision', 'providerId', 'enabled'] : ['expectedRevision', 'providerId', 'confirm'];
98
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).length !== fields.length
99
+ || fields.some(key => !Object.hasOwn(input, key))) fail('管理请求字段不符合约定');
100
+ if (input.expectedRevision !== settingsRevision(catalog)) throw Object.assign(new Error('配置已变化,请重新读取后核对'), { code: 'IRIS_CONFIG_CHANGED' });
101
+ if (command === 'settings.policies') applyFunctionPolicies(catalog, input.assignments);
102
+ else {
103
+ const matches = catalog.providers.filter(provider => provider.id === input.providerId);
104
+ if (matches.length !== 1) fail('供应商不存在或ID重复');
105
+ if (command === 'settings.enabled') {
106
+ if (typeof input.enabled !== 'boolean') fail('enabled 必须是布尔值');
107
+ matches[0].enabled = input.enabled;
108
+ } else {
109
+ if (input.confirm !== true) fail('请明确确认删除此供应商');
110
+ assertRemovable(matches[0]);
111
+ const pool = modelPool(catalog.providers, { includeHostVision: true });
112
+ for (const [cap, assignment] of Object.entries(catalog.assignments || {})) {
113
+ const policy = functionPolicy(assignment, cap);
114
+ catalog.assignments[cap] = { ...policy, models: savedAssignmentRefs(pool, cap, policy.models)
115
+ .filter(ref => parseModelRef(ref)?.providerId !== input.providerId) };
116
+ }
117
+ catalog.providers = catalog.providers.filter(provider => provider !== matches[0]);
118
+ }
119
+ }
120
+ signal?.throwIfAborted(); writeConfigChange(configPath, catalog, before, {});
121
+ return projectProviderSettings(catalog, settingsRevision(catalog), registry);
122
+ }
123
+ if (command === 'models.workflow-check' || command === 'settings.workflow-check') {
124
+ const settings = command === 'settings.workflow-check';
125
+ const allowed = settings ? ['expectedRevision', 'modelRef', 'workflowParameters', 'providerId', 'comfyWorkflow'] : ['model_ref', 'workflow_parameters'];
126
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => !allowed.includes(key))) fail('工作流检查包含不支持的字段');
127
+ if (settings && input.expectedRevision !== settingsRevision(catalog)) throw Object.assign(new Error('配置已变化,请重新读取后检查'), { code: 'IRIS_CONFIG_CHANGED' });
128
+ const draft = settings && input.comfyWorkflow !== undefined;
129
+ const ref = draft ? null : parseModelRef(settings ? input.modelRef : input.model_ref);
130
+ if (draft ? typeof input.providerId !== 'string' || input.modelRef !== undefined : !ref || input.providerId !== undefined) fail('需要明确的模型,或已保存连接与工作流草稿');
131
+ const matches = catalog.providers.filter(provider => provider.id === (draft ? input.providerId : ref.providerId));
132
+ if (matches.length === 1 && matches[0].enabled !== false) assertProviderCredential(matches[0]);
133
+ if (matches.length !== 1 || !isConfiguredProvider(matches[0]) || !draft && !matches[0].models?.some(model => model.id === ref.modelId)) fail('请先配置并启用明确的工作流连接');
134
+ const provider = resolveProviderCredential(draft ? { ...matches[0], comfyWorkflow: checkedComfyWorkflow(input.comfyWorkflow) } : providerForImageModel(matches[0], ref.modelId, registry));
135
+ const scrub = createProviderSecretScrubber([matches[0], provider]);
136
+ if (provider.mediaProtocol !== 'comfyui') fail('此检查仅用于 ComfyUI 工作流');
137
+ const workflow = compileComfyWorkflow(provider.comfyWorkflow, { workflow_parameters: settings ? input.workflowParameters : input.workflow_parameters });
138
+ const network = networkFor(provider, settings ? input.modelRef : input.model_ref);
139
+ const inspect = () => inspectComfyWorkflow(createComfyUiClient(provider, network ? { fetchPort: network.fetch, network } : {}), workflow, { signal });
140
+ const report = await (network ? network.run(inspect, { operation: 'discover' }) : inspect());
141
+ signal?.throwIfAborted();
142
+ if (settingsRevision(loadProviderCatalog(configPath)) !== settingsRevision(catalog)) throw Object.assign(new Error('检查期间配置已变化,请重新检查'), { code: 'IRIS_CONFIG_CHANGED' });
143
+ return { ...scrub(report), revision: settingsRevision(catalog) };
144
+ }
145
+ if (command === 'settings.save' || command === 'settings.discover') {
146
+ if (!input || typeof input.expectedRevision !== 'string' || input.expectedRevision !== settingsRevision(catalog)) {
147
+ throw Object.assign(new Error('配置已变化,请重新读取后核对草稿'), { code: 'IRIS_CONFIG_CHANGED' });
148
+ }
149
+ if (command === 'settings.discover') {
150
+ if (Object.keys(input).some(key => !['expectedRevision', 'providerId'].includes(key))) fail('目录请求包含不支持的字段');
151
+ const found = catalog.providers.filter(item => item.id === input.providerId);
152
+ if (found.length === 1 && found[0].enabled !== false) assertProviderCredential(found[0]);
153
+ if (found.length !== 1 || !isConfiguredProvider(found[0])) fail('请先保存并启用有认证配置的连接');
154
+ const runtimeProvider = resolveProviderCredential(found[0]);
155
+ const discovered = await invokeProviderOperation(adapterFor(runtimeProvider), 'discover', { signal, timeoutMs: 10000 });
156
+ signal?.throwIfAborted();
157
+ if (settingsRevision(loadProviderCatalog(configPath)) !== settingsRevision(catalog)) throw Object.assign(new Error('目录读取期间配置已变化'), { code: 'IRIS_CONFIG_CHANGED' });
158
+ return { contractVersion: 0, revision: settingsRevision(catalog), models: discovered.models.slice(0, 256).map(model => ({
159
+ id: safe(text(typeof model === 'string' ? model : model.id).split(providerApiKey(runtimeProvider) || '\u0000').join('[redacted]')), capabilities: capabilitiesOfDiscoveredModel(model)
160
+ })), truncated: discovered.models.length > 256, persisted: false };
161
+ }
162
+ applyProviderSettings(catalog, input, registry);
163
+ signal?.throwIfAborted();
164
+ writeConfigChange(configPath, catalog, before, {});
165
+ return projectProviderSettings(catalog, settingsRevision(catalog), registry);
166
+ }
167
+ if (command === 'show') return configSnapshot(catalog, registry);
64
168
  if (command === 'check') {
65
169
  const issues = [];
66
170
  const ids = catalog.providers.map(provider => provider.id);
@@ -70,11 +174,11 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
70
174
  try { normalizeVisionInputLimits(provider.visionInput); } catch (_) { issues.push('invalid_vision_input'); }
71
175
  for (const model of provider.models || []) {
72
176
  if (model?.musicProtocol !== undefined) {
73
- try { normalizeMusicProtocol(model.musicProtocol); } catch (_) { issues.push('invalid_model_music_protocol'); }
177
+ try { normalizeMusicProtocol(model.musicProtocol, registry); } catch (_) { issues.push('invalid_model_music_protocol'); }
74
178
  }
75
179
  try { normalizeVisionInputLimits(model?.visionInput); } catch (_) { issues.push('invalid_model_vision_input'); }
76
180
  if (model?.imageProtocol === undefined) continue;
77
- try { normalizeImageProtocol(model.imageProtocol); } catch (_) { issues.push('invalid_model_image_protocol'); }
181
+ try { normalizeImageProtocol(model.imageProtocol, registry); } catch (_) { issues.push('invalid_model_image_protocol'); }
78
182
  }
79
183
  }
80
184
  for (const [capability, assignment] of Object.entries(catalog.assignments || {})) {
@@ -84,8 +188,10 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
84
188
  }
85
189
  return { valid: issues.length === 0, issues: [...new Set(issues)] };
86
190
  }
87
- if (command === 'models.list') return { models: configSnapshot(catalog).models };
88
- if (command === 'assignments.list') return { ...catalogCapabilitySnapshot(catalog), assignments: assignmentSnapshot(catalog) };
191
+ if (command === 'models.list') return { models: configSnapshot(catalog, registry).models };
192
+ if (command === 'assignments.list') return createProviderSecretScrubber(catalog.providers)({
193
+ ...catalogCapabilitySnapshot(catalog, registry), assignments: assignmentSnapshot(catalog)
194
+ });
89
195
  let result = { command, changed: true };
90
196
  const initialPool = modelPool(catalog.providers);
91
197
  let provider;
@@ -100,7 +206,7 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
100
206
  if (!value || typeof value !== 'object' || Array.isArray(value)) fail('需要 provider 对象');
101
207
  const id = text(value.id);
102
208
  if (command === 'providers.add' ? Boolean(provider) : !provider) fail(command === 'providers.add' ? 'Provider 已存在' : 'Provider 不存在');
103
- const updated = { ...(provider || {}), ...value, id };
209
+ const updated = applyCredentialPatch(provider, { ...value, id });
104
210
  if (updated.visionInput !== undefined) updated.visionInput = normalizeVisionInputLimits(updated.visionInput) || null;
105
211
  let url;
106
212
  try { url = new URL(updated.baseUrl); } catch (_) { fail('baseUrl 必须是 HTTP(S) 地址'); }
@@ -113,13 +219,16 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
113
219
  if (model?.capabilities !== undefined) caps(model.capabilities);
114
220
  }
115
221
  updated.models = updated.models.map(model => typeof model === 'string' ? model : ({ ...model,
116
- ...(model?.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(model.imageProtocol) } : {}),
117
- ...(model?.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(model.musicProtocol) } : {}),
222
+ ...(model?.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(model.imageProtocol, registry) } : {}),
223
+ ...(model?.comfyWorkflow !== undefined ? { comfyWorkflow: checkedComfyWorkflow(model.comfyWorkflow) } : {}),
224
+ ...(model?.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(model.musicProtocol, registry) } : {}),
118
225
  ...(model?.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(model.visionInput) || null } : {}) }));
119
226
  }
120
227
  if (provider) {
121
228
  for (const model of providerModels(provider)) {
122
- if (selectImageProtocol(provider, model.id).mediaProtocol !== selectImageProtocol(updated, model.id).mediaProtocol) {
229
+ if (selectImageProtocol(provider, model.id, registry).mediaProtocol !== selectImageProtocol(updated, model.id, registry).mediaProtocol
230
+ || JSON.stringify(provider.models?.find(entry => entry?.id === model.id)?.comfyWorkflow)
231
+ !== JSON.stringify(updated.models?.find(entry => entry?.id === model.id)?.comfyWorkflow)) {
123
232
  clearModelCapabilityHealth(updated, model.id, 'image-gen');
124
233
  }
125
234
  }
@@ -129,6 +238,7 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
129
238
  else catalog.providers.push(updated);
130
239
  } else if (command === 'providers.remove') {
131
240
  if (!provider) fail('Provider 不存在');
241
+ assertRemovable(provider);
132
242
  catalog.providers.splice(catalog.providers.indexOf(provider), 1);
133
243
  } else if (command.startsWith('models.')) {
134
244
  if (!provider) fail('Provider 不存在');
@@ -138,7 +248,7 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
138
248
  : providerModels(provider).map(({ id, capabilities }) => ({ id, capabilities }));
139
249
  let entry = entries.find(model => model.id === modelId);
140
250
  if (command === 'models.discover') {
141
- const discovered = await invokeProviderOperation(createConfiguredProviderAdapter(provider), 'discover', { signal, timeoutMs: 15000 });
251
+ const discovered = await invokeProviderOperation(adapterFor(provider), 'discover', { signal, timeoutMs: 15000 });
142
252
  const models = discovered.models.map(model => ({ id: text(typeof model === 'string' ? model : model.id), capabilities: capabilitiesOfDiscoveredModel(model), source: 'discovered' }));
143
253
  result = { command, changed: input.apply === true, total: models.length, mediaCount: models.filter(model => model.capabilities.length).length, models: models.map(model => ({ ...model, id: safe(model.id) })) };
144
254
  if (input.apply !== true) return result;
@@ -155,8 +265,8 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
155
265
  const capabilities = input.capabilities === undefined ? undefined : caps(input.capabilities);
156
266
  if (entry) fail('模型已存在;请用 models caps 修改能力');
157
267
  entries.push({ id: modelId, capabilities, source: 'manual',
158
- ...(input.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(input.imageProtocol) } : {}),
159
- ...(input.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(input.musicProtocol) } : {}),
268
+ ...(input.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(input.imageProtocol, registry) } : {}),
269
+ ...(input.musicProtocol !== undefined ? { musicProtocol: normalizeMusicProtocol(input.musicProtocol, registry) } : {}),
160
270
  ...(input.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(input.visionInput) || null } : {}) });
161
271
  } else if (command === 'models.remove') {
162
272
  if (!entry) fail('模型不存在');
@@ -166,16 +276,16 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
166
276
  entry.capabilities = caps(input.capabilities); entry.source = 'manual';
167
277
  } else if (command === 'models.protocol') {
168
278
  if (!entry) fail('模型不存在');
169
- const protocol = normalizeImageProtocol(input.imageProtocol);
170
- const previous = selectImageProtocol(provider, modelId).mediaProtocol;
279
+ const protocol = normalizeImageProtocol(input.imageProtocol, registry);
280
+ const previous = selectImageProtocol(provider, modelId, registry).mediaProtocol;
171
281
  if (protocol === 'auto') delete entry.imageProtocol;
172
282
  else entry.imageProtocol = protocol;
173
283
  provider.models = entries;
174
- if (previous !== selectImageProtocol(provider, modelId).mediaProtocol) clearModelCapabilityHealth(provider, modelId, 'image-gen');
175
- result = { ...result, model_ref: safe(input.model_ref), imageRouting: selectImageProtocol(provider, modelId) };
284
+ if (previous !== selectImageProtocol(provider, modelId, registry).mediaProtocol) clearModelCapabilityHealth(provider, modelId, 'image-gen');
285
+ result = { ...result, model_ref: safe(input.model_ref), imageRouting: selectImageProtocol(provider, modelId, registry) };
176
286
  } else if (command === 'models.music-protocol') {
177
287
  if (!entry) fail('模型不存在');
178
- const protocol = normalizeMusicProtocol(input.musicProtocol);
288
+ const protocol = normalizeMusicProtocol(input.musicProtocol, registry);
179
289
  if (entry.musicProtocol !== protocol) clearModelCapabilityHealth(provider, modelId, 'music-gen');
180
290
  entry.musicProtocol = protocol;
181
291
  result = { ...result, model_ref: safe(input.model_ref), musicProtocol: protocol };
@@ -281,11 +391,13 @@ export function recordCatalogAvailability(configPath, { providerId, modelId, cap
281
391
  }
282
392
 
283
393
  /** 新的自动请求按需读取目录;只读CLI命令与手选/停用不走这里。 */
284
- export async function refreshCatalogDirectories(configPath, catalog, capability, { signal } = {}) {
394
+ export async function refreshCatalogDirectories(configPath, catalog, capability, { signal, registry, resolveProviderNetwork } = {}) {
395
+ if (resolveProviderNetwork !== undefined && typeof resolveProviderNetwork !== 'function') throw new TypeError('resolveProviderNetwork 必须是函数');
285
396
  if (functionPolicy(catalog.assignments?.[capability], capability).mode !== 'auto') return [];
286
397
  return Promise.all(catalog.providers.filter(provider => isConfiguredProvider(provider)
287
398
  && providerModels(provider).some(model => model.capabilities.includes(capability))).map(provider =>
288
- refreshModelDirectory(provider, { signal, save: directory => {
399
+ refreshModelDirectory(provider, { signal, registry,
400
+ ...(resolveProviderNetwork ? { network: resolveProviderNetwork({ providerId: provider.id, provider, networkRequirement: providerNetworkRequirement(provider) }) } : {}), save: directory => {
289
401
  const fresh = loadProviderCatalog(configPath);
290
402
  const before = fs.readFileSync(configPath);
291
403
  const current = fresh.providers.find(item => item.id === provider.id);
@@ -310,7 +422,7 @@ function writeConfigChange(configPath, catalog, before, result, { backup = true
310
422
  fs.writeFileSync(backupPath, before, { flag: 'wx', mode: 0o600 });
311
423
  result.backupPath = backupPath;
312
424
  }
313
- atomicWritePrivate(configPath, JSON.stringify(catalog, null, 2) + '\n');
425
+ atomicWritePrivate(configPath, JSON.stringify(encodeProviderConfig(catalog), null, 2) + '\n');
314
426
  return result;
315
427
  } finally { fs.closeSync(fd); fs.unlinkSync(lock); }
316
428
  }
@@ -0,0 +1,90 @@
1
+ /** Provider author boundary v1; independent from Plugin/Adapter v0 and persistent Task formats. */
2
+ export const PROVIDER_FACTORY_CONTEXT_VERSION: 1;
3
+ export type ProviderCapability = 'image' | 'video' | 'tts' | 'music' | 'transcribe';
4
+ export type ProviderMethod = 'discover' | 'submit' | 'poll' | 'cancel' | 'download' | 'mapError';
5
+ export type ProviderAcceptance = 'not_accepted' | 'accepted' | 'unknown';
6
+ export type ProviderStage = 'validate' | 'prepare' | 'upload' | 'submit' | 'response' | 'poll' | 'cancel' | 'download' | 'persist' | 'emit';
7
+ export type ProviderErrorCategory = 'invalid_request' | 'authentication' | 'quota' | 'rate_limit' | 'network' | 'timeout' | 'aborted' | 'provider' | 'protocol' | 'local_io' | 'unknown';
8
+ export interface ProviderErrorRecord {
9
+ readonly stage: ProviderStage;
10
+ readonly category: ProviderErrorCategory;
11
+ readonly acceptance: ProviderAcceptance;
12
+ readonly retryable: boolean;
13
+ readonly safeMessage: string;
14
+ readonly httpStatus?: number;
15
+ readonly providerCode?: string;
16
+ }
17
+ export interface ProviderNetwork {
18
+ /** Borrowed Core port: only inside an awaited lifecycle method, including all body reads and child requests. */
19
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
20
+ }
21
+ export interface ProviderFactoryContext {
22
+ readonly contractVersion: 1;
23
+ readonly network: Readonly<ProviderNetwork>;
24
+ /** Selected raw model ID when Core is resolving an existing Task. */
25
+ readonly modelId?: string;
26
+ }
27
+ export interface ProviderConnection {
28
+ readonly id: string;
29
+ readonly baseUrl?: string;
30
+ readonly mediaBaseUrl?: string;
31
+ /** Runtime value resolved by Core. Never persist the connection or read another credential source. */
32
+ readonly apiKey?: string;
33
+ readonly auth?: string;
34
+ readonly [field: string]: unknown;
35
+ }
36
+ export interface ProviderOperationContext {
37
+ readonly signal?: AbortSignal;
38
+ readonly taskId?: string;
39
+ readonly attemptId?: string;
40
+ readonly modelRef?: string;
41
+ readonly providerId?: string;
42
+ readonly attempt?: Readonly<{ id: string; ordinal: number; providerId: string; model: string }>;
43
+ }
44
+ export interface ProviderImageInput { readonly bytes: Uint8Array; readonly mediaType: string; }
45
+ export interface ProviderSubmitRequest {
46
+ readonly capability: ProviderCapability;
47
+ /** Raw supplier model ID, never providerId::modelId. */
48
+ readonly model: string;
49
+ readonly input: Readonly<Record<string, unknown> & { image?: ProviderImageInput }>;
50
+ readonly signal?: AbortSignal;
51
+ readonly timeoutMs?: number;
52
+ }
53
+ export type ProviderArtifact = Readonly<{ kind: 'remote-url'; url: string; mediaType?: string }>
54
+ | Readonly<{ kind: 'inline-base64'; data: string; mediaType?: string }>;
55
+ export type ProviderSubmissionResult = Readonly<{ kind: 'accepted'; remoteTaskId: string }>
56
+ | Readonly<{ kind: 'completed'; artifacts: readonly ProviderArtifact[]; value?: unknown }>
57
+ | Readonly<{ kind: 'not_accepted' | 'acceptance_unknown'; error: Error | Partial<ProviderErrorRecord> }>;
58
+ export interface ProviderRemoteRequest {
59
+ readonly capability: ProviderCapability;
60
+ readonly remoteTaskId: string;
61
+ readonly signal?: AbortSignal;
62
+ readonly timeoutMs?: number;
63
+ }
64
+ export type ProviderPollResult = Readonly<{ kind: 'pending'; progress?: string }>
65
+ | Readonly<{ kind: 'succeeded'; artifacts: readonly ProviderArtifact[]; value?: unknown }>
66
+ | Readonly<{ kind: 'failed' | 'canceled' | 'unknown'; error?: Error | Partial<ProviderErrorRecord>; message?: string }>;
67
+ export type ProviderCancelResult = Readonly<{ kind: 'canceled' }>
68
+ | Readonly<{ kind: 'not_supported'; reason?: string }>
69
+ | Readonly<{ kind: 'unknown'; error?: Error | Partial<ProviderErrorRecord>; message?: string }>;
70
+ export interface ProviderDownloadRequest {
71
+ readonly capability: ProviderCapability;
72
+ readonly artifact: ProviderArtifact;
73
+ /** Core-owned staging path; write only this new file, never Core metadata or another root. */
74
+ readonly targetPath: string;
75
+ readonly signal?: AbortSignal;
76
+ readonly timeoutMs?: number;
77
+ }
78
+ export interface ProviderDiscoveryRequest { readonly signal?: AbortSignal; readonly timeoutMs?: number; }
79
+ export interface ProviderDiscoveryResult {
80
+ readonly models: readonly (string | Readonly<{ id: string; capabilities?: readonly string[] }>)[];
81
+ }
82
+ export interface ProviderOperations {
83
+ submit(request: ProviderSubmitRequest, context: ProviderOperationContext): ProviderSubmissionResult | Promise<ProviderSubmissionResult>;
84
+ /** Synchronous. The any input retains v0 identity mappers; returned records are normalized by Core. */
85
+ mapError(error: any, context: Partial<ProviderErrorRecord>): Error | Partial<ProviderErrorRecord> | undefined;
86
+ discover?(request: ProviderDiscoveryRequest, context: ProviderOperationContext): ProviderDiscoveryResult | Promise<ProviderDiscoveryResult>;
87
+ poll?(request: ProviderRemoteRequest, context: ProviderOperationContext): ProviderPollResult | Promise<ProviderPollResult>;
88
+ cancel?(request: ProviderRemoteRequest, context: ProviderOperationContext): ProviderCancelResult | Promise<ProviderCancelResult>;
89
+ download?(request: ProviderDownloadRequest, context: ProviderOperationContext): Readonly<{ bytes: number }> | Promise<Readonly<{ bytes: number }>>;
90
+ }
@@ -0,0 +1,42 @@
1
+ // Public author context, backed by Core-owned clients. No proxy/resource-management ports.
2
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
+ import { ProviderContractError } from './provider-contract.js';
4
+ import { assertProviderNetwork } from './provider-network.js';
5
+ import { providerNetworkRequirement, sameNetworkRequirement, networkPolicyError } from './network-policy.js';
6
+ import { modelRef } from './models.js';
7
+
8
+ export const PROVIDER_FACTORY_CONTEXT_VERSION = 1;
9
+ const managed = new WeakSet();
10
+ export function markProviderContextAdapter(adapter) { managed.add(adapter); return adapter; }
11
+ export function hasProviderContext(adapter) { return managed.has(adapter); }
12
+
13
+ /** Only the registry creates a context; callers borrow fetch for one awaited lifecycle method. */
14
+ export function createProviderContext(provider, options, inheritedNetwork) {
15
+ if (options?.modelId !== undefined && typeof options.modelId !== 'string') throw new TypeError('Provider modelId 必须是字符串');
16
+ const requirement = providerNetworkRequirement(provider);
17
+ if (requirement && (!options?.network || !sameNetworkRequirement(requirement, options.network.networkRequirement)
18
+ || options.network.providerId !== provider.id)) {
19
+ throw networkPolicyError('IRIS_NETWORK_CONTEXT_REQUIRED', '统一 Provider 需要原配置匹配的网络上下文,不会借用默认出口');
20
+ }
21
+ const network = assertProviderNetwork(options?.network === undefined ? inheritedNetwork() : options.network), scope = new AsyncLocalStorage();
22
+ const fetch = (...args) => {
23
+ if (!scope.getStore()?.active) {
24
+ throw Object.assign(new ProviderContractError('Provider 网络端口只可在当前完整生命周期方法内使用;请等待正文和子请求完成', {
25
+ stage: 'prepare', category: 'invalid_request', acceptance: 'not_accepted', providerCode: 'IRIS_PROVIDER_CONTEXT_OPERATION_REQUIRED'
26
+ }), { code: 'IRIS_PROVIDER_CONTEXT_OPERATION_REQUIRED' });
27
+ }
28
+ return network.fetch(...args);
29
+ };
30
+ const context = Object.freeze({ contractVersion: PROVIDER_FACTORY_CONTEXT_VERSION, network: Object.freeze({ fetch }),
31
+ ...(options?.modelId !== undefined ? { modelId: options.modelId } : {}) });
32
+ function invoke(name, action, args) {
33
+ const request = args[0], operation = { ...args[1], providerId: provider.id, operation: name };
34
+ if (!operation.modelRef && typeof request?.model === 'string') operation.modelRef = modelRef(provider.id, request.model);
35
+ return network.run(() => scope.run({ active: true }, async () => {
36
+ const current = scope.getStore();
37
+ try { return await action(...args); }
38
+ finally { current.active = false; }
39
+ }), operation);
40
+ }
41
+ return { context, invoke };
42
+ }
@@ -0,0 +1,97 @@
1
+ // Explicit process-environment references. Never mutate/persist resolved providers.
2
+ import { ProviderContractError } from './provider-contract.js';
3
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
4
+ const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
5
+
6
+ function validateEnvironmentCredential(provider) {
7
+ if (provider?.apiKeyEnv === undefined) return;
8
+ if (typeof provider.apiKeyEnv !== 'string' || !ENV_NAME.test(provider.apiKeyEnv)) {
9
+ fail('IRIS_CONFIG_INPUT_INVALID', 'apiKeyEnv 必须是最多128字符的环境变量名');
10
+ }
11
+ if (provider.apiKey !== undefined || provider.dshProvider !== undefined) {
12
+ fail('IRIS_CONFIG_INPUT_INVALID', '环境凭据引用不能与内联密钥或 DSH 连接引用同时保存');
13
+ }
14
+ }
15
+
16
+ /** Persistent sources remain exclusive; Host-resolved copies never pass this storage boundary. */
17
+ export function validateProviderCredential(provider) {
18
+ for (const field of ['baseUrl', 'mediaBaseUrl', 'apiKey']) {
19
+ if (provider?.[field] !== undefined && typeof provider[field] !== 'string') {
20
+ fail('IRIS_CONFIG_INPUT_INVALID', field + ' 必须是字符串');
21
+ }
22
+ }
23
+ validateEnvironmentCredential(provider);
24
+ if (provider?.dshProvider !== undefined
25
+ && (['apiKey', 'apiKeyEnv', 'baseUrl'].some(field => provider[field] !== undefined)
26
+ || provider.mediaBaseUrl !== undefined && provider.mediaBaseUrl !== '')) {
27
+ fail('IRIS_CONFIG_INPUT_INVALID', 'DSH 连接引用不能同时保存内联密钥、环境引用或手动 Base URL(含媒体地址);请新建手动供应商');
28
+ }
29
+ }
30
+
31
+ export function providerCredentialStatus(provider) {
32
+ if (provider?.apiKeyEnv === undefined) return typeof provider?.apiKey === 'string' && provider.apiKey.trim() ? 'ready' : 'missing';
33
+ const value = process.env[provider.apiKeyEnv];
34
+ if (typeof value !== 'string' || !value.trim()) return 'missing';
35
+ return value.length > 8192 || /[^\x20-\x7e]/.test(value) ? 'invalid' : 'ready';
36
+ }
37
+
38
+ /** Non-throwing for status/cache projections. Network boundaries assert readiness. */
39
+ export function providerCredentialValue(provider) {
40
+ if (provider?.apiKeyEnv === undefined) return String(provider?.apiKey || '');
41
+ return providerCredentialStatus(provider) === 'ready' ? process.env[provider.apiKeyEnv].trim() : '';
42
+ }
43
+
44
+ export function assertProviderCredential(provider) {
45
+ // Runtime inputs may be ephemeral DSH copies with a resolved URL/key; stored sources were checked on read/write.
46
+ validateEnvironmentCredential(provider);
47
+ if (provider?.apiKeyEnv !== undefined && provider.auth !== 'none' && providerCredentialStatus(provider) !== 'ready') {
48
+ fail('IRIS_PROVIDER_CREDENTIAL_UNAVAILABLE', '环境凭据未就绪:请检查 Core 进程中的指定变量,值须为非空、最多8192字符的可打印 ASCII 文本');
49
+ }
50
+ }
51
+
52
+ /** An explicit source edit replaces the previous source, never two at once. */
53
+ export function applyCredentialPatch(previous, patch) {
54
+ const next = { ...previous, ...patch };
55
+ if (Object.hasOwn(patch, 'apiKeyEnv') && !Object.hasOwn(patch, 'apiKey')) delete next.apiKey;
56
+ if (Object.hasOwn(patch, 'apiKey') && !Object.hasOwn(patch, 'apiKeyEnv')) delete next.apiKeyEnv;
57
+ validateProviderCredential(next);
58
+ return next;
59
+ }
60
+
61
+ /** Legacy/custom transports receive one call's key through their existing API. */
62
+ export function resolveProviderCredential(provider) {
63
+ assertProviderCredential(provider);
64
+ if (provider.apiKeyEnv === undefined) return provider;
65
+ const { apiKeyEnv, ...runtime } = provider;
66
+ return { ...runtime, apiKey: provider.auth === 'none' ? '' : providerCredentialValue(provider) };
67
+ }
68
+
69
+ /** Capture explicitly configured sources once, including retained auth:none keys. */
70
+ export function createProviderSecretScrubber(providers) {
71
+ const keys = providers.flatMap(provider => {
72
+ const raw = provider.apiKeyEnv === undefined ? provider.apiKey : process.env[provider.apiKeyEnv];
73
+ return typeof raw === 'string' && raw ? [...new Set([raw, raw.trim()].filter(Boolean))] : [];
74
+ }).sort((a, b) => b.length - a.length);
75
+ const scrub = item => typeof item === 'string' ? keys.reduce((text, key) => text.split(key).join('[redacted]'), item)
76
+ : Array.isArray(item) ? item.map(scrub) : item && typeof item === 'object'
77
+ ? Object.fromEntries(Object.entries(item).map(([key, entry]) => [scrub(key), scrub(entry)])) : item;
78
+ return scrub;
79
+ }
80
+
81
+ export function scrubProviderSecrets(value, providers) {
82
+ return createProviderSecretScrubber(providers)(value);
83
+ }
84
+
85
+ /** Discovery metadata is untrusted; never turn an echoed key into a model ID. */
86
+ export function assertDiscoveryCredentials(value, key) {
87
+ const models = Array.isArray(value) ? value : value?.models;
88
+ if (!key || !Array.isArray(models)) return;
89
+ if (models.some(model => {
90
+ const id = String(typeof model === 'string' ? model : model?.id || '').trim();
91
+ return id.includes(key);
92
+ })) {
93
+ throw new ProviderContractError('模型目录包含认证内容,已拒绝读取与保存;请检查上游服务', {
94
+ stage: 'response', category: 'protocol', acceptance: 'not_accepted', providerCode: 'IRIS_PROVIDER_DIRECTORY_CREDENTIALS'
95
+ });
96
+ }
97
+ }
@@ -0,0 +1,56 @@
1
+ import { catalogCapabilitySnapshot } from './provider-catalog.js';
2
+ import { providerModels, functionPolicy, parseModelRef } from './models.js';
3
+ import { isConfiguredProvider, supportsModelCapability } from './provider-protocol.js';
4
+ import { healthObservationState, newestHealthEvidence, modelRateLimit } from './provider-health.js';
5
+ import { listCoreTasks, findFlowTask } from './core-tasks.js';
6
+ import { flowReferences } from './flow-store.js';
7
+ import { providerCredentialStatus, scrubProviderSecrets } from './provider-credentials.js';
8
+
9
+ /** Uses the same routing and health rules as submissions. No discovery, network calls or writes. */
10
+ export function projectProviderDiagnostics(catalog, registry) {
11
+ const routes = catalogCapabilitySnapshot(catalog, registry).capabilities;
12
+ const capabilities = routes.map(route => {
13
+ const models = catalog.providers.flatMap(provider => providerModels(provider).filter(model => model.capabilities.includes(route.capability)).map(model => {
14
+ const observation = provider.health?.observations?.find(item => item.modelId === model.id && item.capability === route.capability);
15
+ const legacy = model.verified?.[route.capability];
16
+ const evidence = observation || (legacy ? { [legacy.ok ? 'lastSuccess' : ['auth', 'authentication', 'permission'].includes(legacy.category) ? 'lastFailure' : 'lastTransient']: legacy } : undefined);
17
+ const limit = modelRateLimit(provider, model.id);
18
+ const latest = newestHealthEvidence(evidence);
19
+ const reason = provider.enabled === false ? 'provider_disabled'
20
+ : provider.auth !== 'none' && provider.apiKeyEnv && providerCredentialStatus(provider) !== 'ready' ? 'credential_unavailable'
21
+ : !isConfiguredProvider(provider) ? 'connection_unconfigured'
22
+ : route.mode === 'disabled' ? 'function_disabled' : model.source === 'discovered' ? 'unconfirmed'
23
+ : !supportsModelCapability(provider, model.id, route.capability, { registry }) ? 'protocol_unsupported'
24
+ : model.unavailableCapabilities?.includes(route.capability) ? 'model_missing' : limit ? limit.until ? 'cooling' : 'quota'
25
+ : !route.candidates.includes(model.ref) ? 'outside_pool' : null;
26
+ return { ref: model.ref, providerId: provider.id, providerName: provider.name || provider.id, modelId: model.id,
27
+ eligible: !reason, reason, rank: reason ? null : route.candidates.indexOf(model.ref) + 1,
28
+ state: healthObservationState(evidence), evidence: latest ? { at: latest.at, source: ['probe', 'task'].includes(latest.source) ? latest.source : 'unknown',
29
+ category: ['success', 'authentication', 'auth', 'permission', 'rate_limit', 'quota', 'network', 'timeout', 'protocol', 'unknown'].includes(latest.category) ? latest.category : 'unknown' } : null,
30
+ ...(limit?.until ? { retryAt: limit.until } : {}),
31
+ comfy: Boolean(provider.models?.find(item => item?.id === model.id)?.comfyWorkflow) };
32
+ }));
33
+ return { capability: route.capability, mode: route.mode, candidates: models.filter(model => model.eligible).sort((a, b) => a.rank - b.rank).map(model => model.ref),
34
+ verified: models.filter(model => model.eligible && model.state === 'verified').length, models };
35
+ });
36
+ const result = { contractVersion: 0, capabilities };
37
+ return scrubProviderSecrets(result, catalog.providers);
38
+ }
39
+
40
+ /** Current root only, under the caller's Core lease; unknown/corrupt records must fail visibly. */
41
+ export function providerDependencies(root, providerId) {
42
+ const tasks = []; let offset = 0, page;
43
+ do { page = listCoreTasks(root, { provider_id: providerId, offset, limit: 200 }); tasks.push(...page.tasks); offset += page.limit; } while (offset < page.total);
44
+ const recoverable = task => task.acceptance === 'unknown' || ['none', 'unknown'].includes(task.outcome)
45
+ || task.outcome === 'succeeded' && task.deliveryState !== 'ready' || task.cancelState === 'requested';
46
+ const blockingTasks = tasks.filter(recoverable).map(task => task.id);
47
+ const blockingRuns = flowReferences(root).filter(({ run }) => parseModelRef(run.input.model_ref)?.providerId === providerId)
48
+ .filter(({ run }) => { const task = findFlowTask(root, run); return !task || recoverable(task); }).map(({ run }) => run.id);
49
+ return { scope: 'current-core-root', blockingTasks, blockingRuns, historicalTasks: tasks.length };
50
+ }
51
+
52
+ export function providerRemovalImpact(catalog, providerId) {
53
+ const provider = catalog.providers.find(item => item.id === providerId);
54
+ return { modelCount: provider ? providerModels(provider).length : 0, functions: Object.entries(catalog.assignments || {}).filter(([cap, value]) =>
55
+ functionPolicy(value, cap).models.some(ref => parseModelRef(ref)?.providerId === providerId)).map(([cap]) => cap) };
56
+ }
@@ -0,0 +1,29 @@
1
+ // Experimental v0 package boundary. This is not the complete Core SDK.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { builtinProviderExtension } from './provider-adapters.js';
6
+ import { createExtensionRegistry } from './provider-registry.js';
7
+
8
+ export { defineProviderAdapter, PROVIDER_ADAPTER_CONTRACT_VERSION } from './provider-adapter.js';
9
+ export { ProviderContractError } from './provider-contract.js';
10
+ export { normalizeMediaEndpoint } from './provider-protocol.js';
11
+ export { PROVIDER_EXTENSION_CONTRACT_VERSION } from './provider-registry.js';
12
+
13
+ export function createProviderRegistry({ extensions = [] } = {}) {
14
+ if (!Array.isArray(extensions)) throw new TypeError('extensions 必须是工厂数组');
15
+ return createExtensionRegistry([builtinProviderExtension, ...extensions]);
16
+ }
17
+
18
+ /** Trusted code only. Explicit absolute entry files; no discovery or package installation. */
19
+ export async function loadProviderRegistry(entries = []) {
20
+ const factories = [];
21
+ for (const entry of entries) {
22
+ if (typeof entry !== 'string' || !path.isAbsolute(entry) || !/\.m?js$/.test(entry)
23
+ || !fs.statSync(entry).isFile()) throw new TypeError('Provider 扩展入口必须是明确的 JS/MJS 文件绝对路径');
24
+ const module = await import(pathToFileURL(fs.realpathSync(entry)).href);
25
+ if (typeof module.default !== 'function') throw new TypeError('Provider 扩展须默认导出实例工厂函数');
26
+ factories.push(module.default);
27
+ }
28
+ return createProviderRegistry({ extensions: factories });
29
+ }