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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.en.md +20 -13
  3. package/README.md +20 -13
  4. package/bin/dsh-iris.js +143 -51
  5. package/docs/AGENT_CORE_QUICKSTART.md +2 -2
  6. package/docs/AGENT_QUICKSTART.md +1 -1
  7. package/docs/ARCHITECTURE.md +5 -5
  8. package/docs/ARTIFACT_MANIFEST.md +4 -0
  9. package/docs/AUDIO_FLOWS.md +20 -4
  10. package/docs/BETA_SCOPE.md +42 -0
  11. package/docs/CONTACT_SHEET.md +1 -1
  12. package/docs/CORE_CONNECTIONS.md +3 -1
  13. package/docs/CORE_QUICKSTART.md +7 -5
  14. package/docs/CORE_RUNTIME_CONTRACT.md +3 -3
  15. package/docs/CORE_SERVICE.md +9 -5
  16. package/docs/CORE_SKILLS.md +2 -2
  17. package/docs/DSH_COMPATIBILITY.md +11 -1
  18. package/docs/DSH_CORE_MIGRATION.md +6 -0
  19. package/docs/DSH_RUNTIME.md +2 -2
  20. package/docs/HEADLESS_CLI.md +29 -4
  21. package/docs/HOST_ADAPTER_CONTRACT.md +5 -3
  22. package/docs/IMAGE_FLOW.md +2 -2
  23. package/docs/IMAGE_GENERATION.md +1 -1
  24. package/docs/LOCAL_API.md +2 -2
  25. package/docs/LOCAL_API_CONTRACT.md +17 -15
  26. package/docs/LOCAL_CREATION.md +2 -2
  27. package/docs/LOCAL_OPERATIONS.md +2 -2
  28. package/docs/LOCAL_SELECTION.md +1 -1
  29. package/docs/LOCAL_WEB.md +11 -5
  30. package/docs/MCP.md +30 -9
  31. package/docs/MEDIA_SCHEDULING.md +72 -0
  32. package/docs/NETWORK_RESOURCES.md +106 -0
  33. package/docs/NETWORK_ROUTING.md +103 -0
  34. package/docs/NETWORK_WORKBENCH.md +65 -0
  35. package/docs/PANEL_EXTENSIONS.md +1 -1
  36. package/docs/PLUGINS.md +9 -5
  37. package/docs/PLUGIN_DEVELOPMENT.md +39 -0
  38. package/docs/PLUGIN_STARTUP.md +15 -2
  39. package/docs/PROVIDER_ADAPTER_CONTRACT.md +8 -2
  40. package/docs/PROVIDER_CREDENTIALS.md +67 -0
  41. package/docs/PROVIDER_EXTENSIONS.md +8 -4
  42. package/docs/PROVIDER_HEALTH.md +4 -2
  43. package/docs/PROVIDER_NETWORK.md +45 -0
  44. package/docs/PROVIDER_PLUGIN_CONTRACT.md +24 -0
  45. package/docs/PROVIDER_SETTINGS.md +13 -7
  46. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +3 -1
  47. package/docs/ROADMAP.md +6 -6
  48. package/docs/SECURITY.md +1 -1
  49. package/docs/SYNC_DELIVERY_RECOVERY.md +5 -1
  50. package/docs/VIDEO_FLOWS.md +89 -0
  51. package/docs/VISION_MODEL.md +2 -0
  52. package/docs/WEB_PROTOTYPE.md +3 -3
  53. package/docs/WEB_RECOVERY.md +2 -2
  54. package/docs/WORKBENCH_ARTIFACTS.md +6 -0
  55. package/docs/WORKBENCH_LOGIN.md +2 -2
  56. package/docs/releases/0.3.0-beta.1.md +34 -0
  57. package/docs/screenshots.md +2 -2
  58. package/lib/actions.js +5 -4
  59. package/lib/adapters.js +58 -41
  60. package/lib/api.js +23 -13
  61. package/lib/application-runtime.js +44 -15
  62. package/lib/application-writer.js +193 -0
  63. package/lib/client.d.ts +3 -3
  64. package/lib/client.js +62 -25
  65. package/lib/comfyui-adapter.js +3 -2
  66. package/lib/comfyui-client.js +10 -1
  67. package/lib/command-service.js +18 -5
  68. package/lib/config.js +13 -9
  69. package/lib/configured-image-runtime.js +28 -9
  70. package/lib/connection-skill.js +9 -0
  71. package/lib/core-artifact-store.js +68 -26
  72. package/lib/core-bridge-tools.js +5 -3
  73. package/lib/core-capability-catalog.js +21 -11
  74. package/lib/core-delivery-cache.js +8 -3
  75. package/lib/core-service-client.js +6 -2
  76. package/lib/core-tasks.js +86 -11
  77. package/lib/creation-request-store.js +8 -5
  78. package/lib/doctor.js +2 -2
  79. package/lib/dsh-core-adapter.js +61 -9
  80. package/lib/dsh-provider-config.js +1 -1
  81. package/lib/dsh-service-bridge.js +1 -0
  82. package/lib/flow-store.js +64 -17
  83. package/lib/image-flow.js +39 -17
  84. package/lib/index.js +10 -5
  85. package/lib/local-api-projection.js +6 -2
  86. package/lib/local-api.js +137 -49
  87. package/lib/local-creation.js +12 -5
  88. package/lib/local-operations.js +2 -2
  89. package/lib/local-request-root.js +2 -2
  90. package/lib/local-web-assets.js +2 -0
  91. package/lib/mcp-http.js +82 -9
  92. package/lib/mcp-server.js +23 -1
  93. package/lib/media-scheduler.js +194 -0
  94. package/lib/model-directory-service.js +25 -8
  95. package/lib/music-transport.js +4 -2
  96. package/lib/network-config-service.js +123 -0
  97. package/lib/network-config.js +127 -0
  98. package/lib/network-doctor.js +94 -0
  99. package/lib/network-management.js +51 -0
  100. package/lib/network-policy.js +19 -0
  101. package/lib/network-route-evidence.js +38 -0
  102. package/lib/network-routing.js +283 -0
  103. package/lib/operation-runner.js +83 -38
  104. package/lib/operation-store.js +6 -6
  105. package/lib/plugins.d.ts +25 -6
  106. package/lib/plugins.js +2 -1
  107. package/lib/provider-adapter.js +2 -2
  108. package/lib/provider-adapters.js +37 -5
  109. package/lib/provider-catalog.js +33 -16
  110. package/lib/provider-config-format.js +51 -0
  111. package/lib/provider-config-service.js +52 -22
  112. package/lib/provider-context.d.ts +90 -0
  113. package/lib/provider-context.js +42 -0
  114. package/lib/provider-credentials.js +97 -0
  115. package/lib/provider-diagnostics.js +5 -5
  116. package/lib/provider-health.js +10 -5
  117. package/lib/provider-monitor.js +5 -3
  118. package/lib/provider-network.js +52 -0
  119. package/lib/provider-protocol.js +3 -2
  120. package/lib/provider-registry.js +15 -5
  121. package/lib/provider-response.js +10 -0
  122. package/lib/provider-settings.js +14 -12
  123. package/lib/provider-task-runner.js +113 -37
  124. package/lib/provider-transport.js +16 -0
  125. package/lib/render.js +11 -1
  126. package/lib/scheduling-policy.js +66 -0
  127. package/lib/tasks.js +3 -2
  128. package/lib/video-flow-input.js +16 -0
  129. package/lib/video-input.js +12 -5
  130. package/lib/vision-model-routing.js +2 -0
  131. package/lib/web/app.js +107 -46
  132. package/lib/web/connections.js +151 -12
  133. package/lib/web/creation.js +29 -25
  134. package/lib/web/flow-view.js +4 -3
  135. package/lib/web/gallery.js +17 -16
  136. package/lib/web/index.html +58 -5
  137. package/lib/web/media-client.js +18 -4
  138. package/lib/web/network.js +232 -0
  139. package/lib/web/plugin-panel.js +4 -2
  140. package/lib/web/plugin-status.js +140 -0
  141. package/lib/web/providers.js +34 -13
  142. package/lib/web/recovery.js +2 -2
  143. package/lib/web/styles.css +54 -1
  144. package/lib/web/transport.js +6 -1
  145. package/lib/workbench-artifacts.js +3 -2
  146. package/package.json +5 -2
  147. package/skills/iris-core/references/comfyui.md +1 -1
  148. package/templates/plugin-starter/LICENSE +21 -0
  149. package/templates/plugin-starter/README.md +74 -0
  150. package/templates/plugin-starter/assets/peach.png +0 -0
  151. package/templates/plugin-starter/assets/violet.png +0 -0
  152. package/templates/plugin-starter/index.mjs +27 -0
  153. package/templates/plugin-starter/package.json +18 -0
  154. package/templates/plugin-starter/scripts/core-cli.mjs +20 -0
  155. package/templates/plugin-starter/scripts/environment.mjs +65 -0
  156. package/templates/plugin-starter/scripts/mcp-probe.mjs +22 -0
  157. package/templates/plugin-starter/scripts/setup.mjs +42 -0
  158. package/templates/plugin-starter/scripts/smoke.mjs +76 -0
  159. package/templates/plugin-starter/web/board.css +27 -0
  160. package/templates/plugin-starter/web/board.mjs +160 -0
  161. package/templates/plugin-starter/web/png.mjs +23 -0
  162. package/templates/provider-starter/LICENSE +21 -0
  163. package/templates/provider-starter/README.md +50 -0
  164. package/templates/provider-starter/index.d.mts +5 -0
  165. package/templates/provider-starter/index.mjs +165 -0
  166. package/templates/provider-starter/package.json +13 -0
  167. package/templates/provider-starter/scripts/core-cli.mjs +22 -0
  168. package/templates/provider-starter/scripts/environment.mjs +122 -0
  169. package/templates/provider-starter/scripts/fixture.mjs +54 -0
  170. package/templates/provider-starter/scripts/mcp-probe.mjs +21 -0
  171. package/templates/provider-starter/scripts/protocol-smoke.mjs +48 -0
  172. package/templates/provider-starter/scripts/setup.mjs +44 -0
  173. package/templates/provider-starter/scripts/smoke.mjs +145 -0
  174. package/user_guide.md +3 -3
@@ -0,0 +1,51 @@
1
+ // Network-enabled catalogs use an outer envelope which older loaders reject.
2
+ import { CATALOG_NETWORK, checkedNetworkRequirement, sameNetworkRequirement } from './network-policy.js';
3
+
4
+ const fail = message => { throw Object.assign(new Error(message), { code: 'IRIS_PROVIDER_CONFIG_INVALID' }); };
5
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
6
+
7
+ function markedCatalog(catalog, requirement) {
8
+ if (!object(catalog) || !Array.isArray(catalog.providers) || catalog.providers.some(provider => !object(provider))) {
9
+ fail('Provider 配置内容无效');
10
+ }
11
+ const ids = new Set();
12
+ for (const provider of catalog.providers) {
13
+ if (typeof provider.id !== 'string' || !provider.id.trim() || provider.id.length > 256 || /[\x00-\x1f\x7f]/.test(provider.id) || ids.has(provider.id)) fail('网络目录需要明确、唯一且不含控制字符的供应商身份');
14
+ ids.add(provider.id);
15
+ }
16
+ const identity = Object.freeze(checkedNetworkRequirement(requirement));
17
+ return { ...catalog, [CATALOG_NETWORK]: identity,
18
+ providers: catalog.providers.map(provider => ({ ...provider, [CATALOG_NETWORK]: identity })) };
19
+ }
20
+
21
+ export function decodeProviderConfig(parsed) {
22
+ if (object(parsed) && Object.hasOwn(parsed, 'providers') && Object.hasOwn(parsed, 'networkRequirement')) fail('网络目录不能与旧根 providers 字段混用,拒绝忽略网络要求');
23
+ if (!object(parsed) || Object.hasOwn(parsed, 'providers')) return parsed;
24
+ if (parsed.schemaVersion !== 1 || Object.keys(parsed).length !== 3
25
+ || !Object.hasOwn(parsed, 'networkRequirement') || !Object.hasOwn(parsed, 'catalog')) {
26
+ fail('Provider 网络配置包装版本或字段无效;请使用兼容版本,原文件不会自动迁移');
27
+ }
28
+ return markedCatalog(parsed.catalog, parsed.networkRequirement);
29
+ }
30
+
31
+ export function encodeProviderConfig(catalog) {
32
+ const requirement = catalog?.[CATALOG_NETWORK];
33
+ if (requirement === undefined) {
34
+ if (catalog?.providers?.some(provider => provider?.[CATALOG_NETWORK] !== undefined)) fail('Provider 网络配置身份丢失,拒绝写回旧格式');
35
+ return catalog;
36
+ }
37
+ const checked = checkedNetworkRequirement(requirement);
38
+ for (const provider of catalog.providers) {
39
+ const identity = provider?.[CATALOG_NETWORK];
40
+ if (identity !== undefined && !sameNetworkRequirement(checked, identity)) fail('Provider 与目录的网络配置身份不一致');
41
+ }
42
+ const { [CATALOG_NETWORK]: _identity, ...contents } = catalog;
43
+ return { schemaVersion: 1, networkRequirement: checked, catalog: contents };
44
+ }
45
+
46
+ /** Return a new marked runtime catalog; callers explicitly write a new file. */
47
+ export function wrapProviderCatalog(catalog, requirement) {
48
+ const original = encodeProviderConfig(catalog);
49
+ const contents = catalog?.[CATALOG_NETWORK] === undefined ? original : original.catalog;
50
+ return markedCatalog(JSON.parse(JSON.stringify(contents)), requirement);
51
+ }
@@ -16,15 +16,18 @@ import { refreshModelDirectory } from './model-directory-service.js';
16
16
  import { projectProviderSettings, applyProviderSettings, applyFunctionPolicies } from './provider-settings.js';
17
17
  import { checkedComfyWorkflow, compileComfyWorkflow, projectComfyWorkflow } from './comfyui-workflow.js';
18
18
  import { createComfyUiClient } from './comfyui-client.js';
19
+ import { applyCredentialPatch, assertProviderCredential, resolveProviderCredential, createProviderSecretScrubber } from './provider-credentials.js';
19
20
  import { inspectComfyWorkflow } from './comfyui-inspection.js';
21
+ import { encodeProviderConfig } from './provider-config-format.js';
22
+ import { providerNetworkRequirement, sameNetworkRequirement, networkPolicyError } from './network-policy.js';
20
23
 
21
24
  const fail = message => { throw Object.assign(new Error(message), { code: 'IRIS_CONFIG_INPUT_INVALID' }); };
22
25
  const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
23
26
  // Owner edits change configuration, while task health and directory caches can
24
27
  // advance independently. Saves still load fresh records and use a byte CAS under
25
28
  // the file lock, preserving the latest runtime evidence.
26
- function settingsRevision(catalog) {
27
- return hash(JSON.stringify({ ...catalog, providers: catalog.providers.map(({ health, modelDirectory, ...provider }) => provider) }));
29
+ export function settingsRevision(catalog) {
30
+ return hash(JSON.stringify(encodeProviderConfig({ ...catalog, providers: catalog.providers.map(({ health, modelDirectory, ...provider }) => provider) })));
28
31
  }
29
32
  const safe = value => redactProviderMessage(String(value || '').replace(/https?:\/\/[^\s<>"']+/gi, '[URL]'));
30
33
  const text = value => { if (typeof value !== 'string' || !value.trim()) fail('ID 必须是非空字符串'); return value.trim(); };
@@ -34,18 +37,20 @@ const caps = value => {
34
37
  };
35
38
 
36
39
  function assignmentSnapshot(catalog) {
40
+ const scrub = createProviderSecretScrubber(catalog.providers), label = value => safe(scrub(value));
37
41
  const pool = modelPool(catalog.providers, { includeHostVision: true });
38
42
  return Object.fromEntries(Object.entries(catalog.assignments || {}).map(([capability, value]) => {
39
43
  const policy = functionPolicy(value, capability);
40
44
  const refs = policy.models.map(ref => resolvePoolModel(pool, ref, capability));
41
- return [safe(capability), { mode: policy.mode, hostVision: policy.hostVision,
42
- 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 }];
43
47
  }));
44
48
  }
45
49
 
46
50
  export function configSnapshot(catalog, registry) {
47
- return { ...providerCatalogSnapshot(catalog), models: catalog.providers.flatMap(provider => providerModels(provider).map(model => ({
48
- ref: safe(model.ref), providerId: safe(provider.id), id: safe(model.id), capabilities: model.capabilities,
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,
49
54
  ...(model.catalogStatus ? { catalogStatus: model.catalogStatus } : {}),
50
55
  ...(model.unavailableCapabilities ? { unavailableCapabilities: model.unavailableCapabilities } : {}),
51
56
  ...(model.imageProtocol !== undefined ? { imageProtocol: model.imageProtocol } : {}),
@@ -55,14 +60,28 @@ export function configSnapshot(catalog, registry) {
55
60
  comfyWorkflow: projectComfyWorkflow(provider.models.find(entry => entry?.id === model.id).comfyWorkflow) } : {}),
56
61
  ...(model.capabilities.includes('vision') ? { visionInputEffective: visionInputLimits(provider, model.id) } : {}),
57
62
  ...(model.capabilities.includes('image-gen') ? { imageRouting: selectImageProtocol(provider, model.id, registry) } : {}),
58
- verified: Object.fromEntries(Object.entries(model.verified || {}).map(([capability, value]) => [capability, { ok: value.ok === true, at: safe(value.at), category: safe(value.category) }])),
63
+ verified: Object.fromEntries(Object.entries(model.verified || {}).map(([capability, value]) => [capability, { ok: value.ok === true, at: label(value.at), category: label(value.category) }])),
59
64
  ...(model.rateLimited ? { rateLimited: true, rateLimitedAt: modelRateLimit(provider, model.id).at,
60
65
  ...(modelRateLimit(provider, model.id).until ? { retryAt: modelRateLimit(provider, model.id).until }
61
66
  : { reason: modelRateLimit(provider, model.id).reason }) } : {})
62
- }))), assignments: assignmentSnapshot(catalog), routing: catalogCapabilitySnapshot(catalog, registry) };
67
+ }))), assignments: assignmentSnapshot(catalog), routing: catalogCapabilitySnapshot(catalog, registry) });
63
68
  }
64
69
 
65
- export async function executeConfigCommand(configPath, command, input = {}, { signal, probe, registry } = {}) {
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
+ };
66
85
  if (command === 'init') {
67
86
  if (!path.isAbsolute(configPath)) fail('provider-config 必须是绝对路径');
68
87
  ensurePrivateDir(path.dirname(configPath));
@@ -71,7 +90,7 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
71
90
  }
72
91
  const catalog = loadProviderCatalog(configPath);
73
92
  const before = fs.readFileSync(configPath);
74
- if (JSON.stringify(JSON.parse(before)) !== JSON.stringify(catalog)) throw Object.assign(new Error('配置在读取期间发生变化,请重试'), { code: 'IRIS_CONFIG_CHANGED' });
93
+ if (JSON.stringify(JSON.parse(before)) !== JSON.stringify(encodeProviderConfig(catalog))) throw Object.assign(new Error('配置在读取期间发生变化,请重试'), { code: 'IRIS_CONFIG_CHANGED' });
75
94
  if (command === 'settings.show') return projectProviderSettings(catalog, settingsRevision(catalog), registry);
76
95
  if (['settings.policies', 'settings.enabled', 'settings.remove'].includes(command)) {
77
96
  const fields = command === 'settings.policies' ? ['expectedRevision', 'assignments'] : command === 'settings.enabled'
@@ -88,6 +107,7 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
88
107
  matches[0].enabled = input.enabled;
89
108
  } else {
90
109
  if (input.confirm !== true) fail('请明确确认删除此供应商');
110
+ assertRemovable(matches[0]);
91
111
  const pool = modelPool(catalog.providers, { includeHostVision: true });
92
112
  for (const [cap, assignment] of Object.entries(catalog.assignments || {})) {
93
113
  const policy = functionPolicy(assignment, cap);
@@ -109,15 +129,18 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
109
129
  const ref = draft ? null : parseModelRef(settings ? input.modelRef : input.model_ref);
110
130
  if (draft ? typeof input.providerId !== 'string' || input.modelRef !== undefined : !ref || input.providerId !== undefined) fail('需要明确的模型,或已保存连接与工作流草稿');
111
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]);
112
133
  if (matches.length !== 1 || !isConfiguredProvider(matches[0]) || !draft && !matches[0].models?.some(model => model.id === ref.modelId)) fail('请先配置并启用明确的工作流连接');
113
- const provider = draft ? { ...matches[0], comfyWorkflow: checkedComfyWorkflow(input.comfyWorkflow) } : providerForImageModel(matches[0], ref.modelId, registry);
134
+ const provider = resolveProviderCredential(draft ? { ...matches[0], comfyWorkflow: checkedComfyWorkflow(input.comfyWorkflow) } : providerForImageModel(matches[0], ref.modelId, registry));
135
+ const scrub = createProviderSecretScrubber([matches[0], provider]);
114
136
  if (provider.mediaProtocol !== 'comfyui') fail('此检查仅用于 ComfyUI 工作流');
115
137
  const workflow = compileComfyWorkflow(provider.comfyWorkflow, { workflow_parameters: settings ? input.workflowParameters : input.workflow_parameters });
116
- const report = await inspectComfyWorkflow(createComfyUiClient(provider), workflow, { signal });
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());
117
141
  signal?.throwIfAborted();
118
142
  if (settingsRevision(loadProviderCatalog(configPath)) !== settingsRevision(catalog)) throw Object.assign(new Error('检查期间配置已变化,请重新检查'), { code: 'IRIS_CONFIG_CHANGED' });
119
- const key = providerApiKey(provider);
120
- return { ...JSON.parse(key ? JSON.stringify(report).split(key).join('[redacted]') : JSON.stringify(report)), revision: settingsRevision(catalog) };
143
+ return { ...scrub(report), revision: settingsRevision(catalog) };
121
144
  }
122
145
  if (command === 'settings.save' || command === 'settings.discover') {
123
146
  if (!input || typeof input.expectedRevision !== 'string' || input.expectedRevision !== settingsRevision(catalog)) {
@@ -126,12 +149,14 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
126
149
  if (command === 'settings.discover') {
127
150
  if (Object.keys(input).some(key => !['expectedRevision', 'providerId'].includes(key))) fail('目录请求包含不支持的字段');
128
151
  const found = catalog.providers.filter(item => item.id === input.providerId);
152
+ if (found.length === 1 && found[0].enabled !== false) assertProviderCredential(found[0]);
129
153
  if (found.length !== 1 || !isConfiguredProvider(found[0])) fail('请先保存并启用有认证配置的连接');
130
- const discovered = await invokeProviderOperation(createConfiguredProviderAdapter(found[0], { registry }), 'discover', { signal, timeoutMs: 10000 });
154
+ const runtimeProvider = resolveProviderCredential(found[0]);
155
+ const discovered = await invokeProviderOperation(adapterFor(runtimeProvider), 'discover', { signal, timeoutMs: 10000 });
131
156
  signal?.throwIfAborted();
132
157
  if (settingsRevision(loadProviderCatalog(configPath)) !== settingsRevision(catalog)) throw Object.assign(new Error('目录读取期间配置已变化'), { code: 'IRIS_CONFIG_CHANGED' });
133
158
  return { contractVersion: 0, revision: settingsRevision(catalog), models: discovered.models.slice(0, 256).map(model => ({
134
- id: safe(text(typeof model === 'string' ? model : model.id)).split(found[0].apiKey || '\u0000').join('[redacted]'), capabilities: capabilitiesOfDiscoveredModel(model)
159
+ id: safe(text(typeof model === 'string' ? model : model.id).split(providerApiKey(runtimeProvider) || '\u0000').join('[redacted]')), capabilities: capabilitiesOfDiscoveredModel(model)
135
160
  })), truncated: discovered.models.length > 256, persisted: false };
136
161
  }
137
162
  applyProviderSettings(catalog, input, registry);
@@ -164,7 +189,9 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
164
189
  return { valid: issues.length === 0, issues: [...new Set(issues)] };
165
190
  }
166
191
  if (command === 'models.list') return { models: configSnapshot(catalog, registry).models };
167
- if (command === 'assignments.list') return { ...catalogCapabilitySnapshot(catalog, registry), assignments: assignmentSnapshot(catalog) };
192
+ if (command === 'assignments.list') return createProviderSecretScrubber(catalog.providers)({
193
+ ...catalogCapabilitySnapshot(catalog, registry), assignments: assignmentSnapshot(catalog)
194
+ });
168
195
  let result = { command, changed: true };
169
196
  const initialPool = modelPool(catalog.providers);
170
197
  let provider;
@@ -179,7 +206,7 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
179
206
  if (!value || typeof value !== 'object' || Array.isArray(value)) fail('需要 provider 对象');
180
207
  const id = text(value.id);
181
208
  if (command === 'providers.add' ? Boolean(provider) : !provider) fail(command === 'providers.add' ? 'Provider 已存在' : 'Provider 不存在');
182
- const updated = { ...(provider || {}), ...value, id };
209
+ const updated = applyCredentialPatch(provider, { ...value, id });
183
210
  if (updated.visionInput !== undefined) updated.visionInput = normalizeVisionInputLimits(updated.visionInput) || null;
184
211
  let url;
185
212
  try { url = new URL(updated.baseUrl); } catch (_) { fail('baseUrl 必须是 HTTP(S) 地址'); }
@@ -211,6 +238,7 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
211
238
  else catalog.providers.push(updated);
212
239
  } else if (command === 'providers.remove') {
213
240
  if (!provider) fail('Provider 不存在');
241
+ assertRemovable(provider);
214
242
  catalog.providers.splice(catalog.providers.indexOf(provider), 1);
215
243
  } else if (command.startsWith('models.')) {
216
244
  if (!provider) fail('Provider 不存在');
@@ -220,7 +248,7 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
220
248
  : providerModels(provider).map(({ id, capabilities }) => ({ id, capabilities }));
221
249
  let entry = entries.find(model => model.id === modelId);
222
250
  if (command === 'models.discover') {
223
- const discovered = await invokeProviderOperation(createConfiguredProviderAdapter(provider, { registry }), 'discover', { signal, timeoutMs: 15000 });
251
+ const discovered = await invokeProviderOperation(adapterFor(provider), 'discover', { signal, timeoutMs: 15000 });
224
252
  const models = discovered.models.map(model => ({ id: text(typeof model === 'string' ? model : model.id), capabilities: capabilitiesOfDiscoveredModel(model), source: 'discovered' }));
225
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) })) };
226
254
  if (input.apply !== true) return result;
@@ -363,11 +391,13 @@ export function recordCatalogAvailability(configPath, { providerId, modelId, cap
363
391
  }
364
392
 
365
393
  /** 新的自动请求按需读取目录;只读CLI命令与手选/停用不走这里。 */
366
- export async function refreshCatalogDirectories(configPath, catalog, capability, { signal, registry } = {}) {
394
+ export async function refreshCatalogDirectories(configPath, catalog, capability, { signal, registry, resolveProviderNetwork } = {}) {
395
+ if (resolveProviderNetwork !== undefined && typeof resolveProviderNetwork !== 'function') throw new TypeError('resolveProviderNetwork 必须是函数');
367
396
  if (functionPolicy(catalog.assignments?.[capability], capability).mode !== 'auto') return [];
368
397
  return Promise.all(catalog.providers.filter(provider => isConfiguredProvider(provider)
369
398
  && providerModels(provider).some(model => model.capabilities.includes(capability))).map(provider =>
370
- refreshModelDirectory(provider, { signal, registry, save: directory => {
399
+ refreshModelDirectory(provider, { signal, registry,
400
+ ...(resolveProviderNetwork ? { network: resolveProviderNetwork({ providerId: provider.id, provider, networkRequirement: providerNetworkRequirement(provider) }) } : {}), save: directory => {
371
401
  const fresh = loadProviderCatalog(configPath);
372
402
  const before = fs.readFileSync(configPath);
373
403
  const current = fresh.providers.find(item => item.id === provider.id);
@@ -392,7 +422,7 @@ function writeConfigChange(configPath, catalog, before, result, { backup = true
392
422
  fs.writeFileSync(backupPath, before, { flag: 'wx', mode: 0o600 });
393
423
  result.backupPath = backupPath;
394
424
  }
395
- atomicWritePrivate(configPath, JSON.stringify(catalog, null, 2) + '\n');
425
+ atomicWritePrivate(configPath, JSON.stringify(encodeProviderConfig(catalog), null, 2) + '\n');
396
426
  return result;
397
427
  } finally { fs.closeSync(fd); fs.unlinkSync(lock); }
398
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
+ }
@@ -4,6 +4,7 @@ import { isConfiguredProvider, supportsModelCapability } from './provider-protoc
4
4
  import { healthObservationState, newestHealthEvidence, modelRateLimit } from './provider-health.js';
5
5
  import { listCoreTasks, findFlowTask } from './core-tasks.js';
6
6
  import { flowReferences } from './flow-store.js';
7
+ import { providerCredentialStatus, scrubProviderSecrets } from './provider-credentials.js';
7
8
 
8
9
  /** Uses the same routing and health rules as submissions. No discovery, network calls or writes. */
9
10
  export function projectProviderDiagnostics(catalog, registry) {
@@ -15,7 +16,9 @@ export function projectProviderDiagnostics(catalog, registry) {
15
16
  const evidence = observation || (legacy ? { [legacy.ok ? 'lastSuccess' : ['auth', 'authentication', 'permission'].includes(legacy.category) ? 'lastFailure' : 'lastTransient']: legacy } : undefined);
16
17
  const limit = modelRateLimit(provider, model.id);
17
18
  const latest = newestHealthEvidence(evidence);
18
- const reason = provider.enabled === false ? 'provider_disabled' : !isConfiguredProvider(provider) ? 'connection_unconfigured'
19
+ const reason = provider.enabled === false ? 'provider_disabled'
20
+ : provider.auth !== 'none' && provider.apiKeyEnv && providerCredentialStatus(provider) !== 'ready' ? 'credential_unavailable'
21
+ : !isConfiguredProvider(provider) ? 'connection_unconfigured'
19
22
  : route.mode === 'disabled' ? 'function_disabled' : model.source === 'discovered' ? 'unconfirmed'
20
23
  : !supportsModelCapability(provider, model.id, route.capability, { registry }) ? 'protocol_unsupported'
21
24
  : model.unavailableCapabilities?.includes(route.capability) ? 'model_missing' : limit ? limit.until ? 'cooling' : 'quota'
@@ -31,10 +34,7 @@ export function projectProviderDiagnostics(catalog, registry) {
31
34
  verified: models.filter(model => model.eligible && model.state === 'verified').length, models };
32
35
  });
33
36
  const result = { contractVersion: 0, capabilities };
34
- const keys = catalog.providers.map(provider => provider.apiKey).filter(key => typeof key === 'string' && key);
35
- const scrub = value => typeof value === 'string' ? keys.reduce((text, key) => text.split(key).join('[redacted]'), value)
36
- : Array.isArray(value) ? value.map(scrub) : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, scrub(item)])) : value;
37
- return scrub(result);
37
+ return scrubProviderSecrets(result, catalog.providers);
38
38
  }
39
39
 
40
40
  /** Current root only, under the caller's Core lease; unknown/corrupt records must fail visibly. */
@@ -3,7 +3,7 @@ import { isKnownCapability } from './capability.js';
3
3
  import { isModelNotFound, modelAvailabilityBinding, modelUnavailable } from './model-directory.js';
4
4
 
5
5
  const HEALTH_SENSITIVE_FIELDS = new Set([
6
- 'auth', 'type', 'baseUrl', 'mediaBaseUrl', 'apiKey', 'mediaProtocol',
6
+ 'auth', 'type', 'baseUrl', 'mediaBaseUrl', 'apiKey', 'apiKeyEnv', 'mediaProtocol',
7
7
  'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel'
8
8
  ]);
9
9
 
@@ -51,14 +51,18 @@ export const PROVIDER_HEALTH_STATES = Object.freeze([
51
51
 
52
52
  const DEFINITIVE_FAILURES = new Set(['authentication', 'auth', 'permission']);
53
53
  export const MODEL_RATE_LIMIT_COOLDOWN_MS = 60000;
54
+ // Bound supplier hints to Node's supported timer range, including their persisted ISO deadline.
55
+ export const MAX_MODEL_RATE_LIMIT_COOLDOWN_MS = 2_147_483_647;
56
+ const MAX_DATE_MS = 8_640_000_000_000_000;
57
+ const boundedCooldown = delay => Math.min(MAX_MODEL_RATE_LIMIT_COOLDOWN_MS, Math.max(1000, delay));
54
58
 
55
59
  export function retryAfterDelay(value, now = Date.now()) {
56
60
  const raw = String(value ?? '').trim();
57
61
  if (!raw) return MODEL_RATE_LIMIT_COOLDOWN_MS;
58
62
  const seconds = Number(raw);
59
- if (Number.isFinite(seconds) && seconds >= 0) return Math.max(1000, seconds * 1000);
63
+ if (Number.isFinite(seconds) && seconds >= 0) return boundedCooldown(seconds * 1000);
60
64
  const at = Date.parse(raw);
61
- return Number.isFinite(at) && at > now ? at - now : MODEL_RATE_LIMIT_COOLDOWN_MS;
65
+ return Number.isFinite(at) && at > now ? boundedCooldown(at - now) : MODEL_RATE_LIMIT_COOLDOWN_MS;
62
66
  }
63
67
 
64
68
  /** 百炼“免费额度用完即停”返回 403,不应误归类为 Key 无效。 */
@@ -94,12 +98,13 @@ export function recordModelRateLimit(provider, modelId, result) {
94
98
  provider.health.rateLimits ||= [];
95
99
  const at = Date.parse(result.at || '');
96
100
  const timestamp = Number.isFinite(at) ? at : Date.now();
97
- const delay = Number.isFinite(result.retryAfterMs) ? result.retryAfterMs : MODEL_RATE_LIMIT_COOLDOWN_MS;
101
+ const delay = Number.isFinite(result.retryAfterMs) && result.retryAfterMs >= 0
102
+ ? boundedCooldown(result.retryAfterMs) : MODEL_RATE_LIMIT_COOLDOWN_MS;
98
103
  provider.health.rateLimits = provider.health.rateLimits.filter(item => item.modelId !== modelId);
99
104
  provider.health.rateLimits.push({ modelId,
100
105
  at: new Date(timestamp).toISOString(),
101
106
  source: result.source === 'probe' ? 'probe' : 'task', category, httpStatus: status,
102
- ...(reason ? { reason } : { until: result.until || new Date(timestamp + Math.max(1000, delay)).toISOString() }) });
107
+ ...(reason ? { reason } : { until: result.until || new Date(Math.min(MAX_DATE_MS, timestamp + delay)).toISOString() }) });
103
108
  return true;
104
109
  }
105
110
 
@@ -3,12 +3,13 @@ import { loadProviderCatalog } from './provider-catalog.js';
3
3
  import { executeConfigCommand } from './provider-config-service.js';
4
4
  import { modelDirectoryBinding } from './model-directory.js';
5
5
  import { selectMediaProtocol, isConfiguredProvider } from './provider-protocol.js';
6
+ import { assertProviderCredential } from './provider-credentials.js';
6
7
 
7
8
  const AUTOMATIC = new Set(['dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images', 'gemini-web-music', 'comfyui']);
8
- const errorCode = error => ['IRIS_CONFIG_CHANGED', 'IRIS_CONFIG_BUSY', 'IRIS_PROVIDER_OPERATION_UNSUPPORTED'].includes(error.code) ? error.code
9
+ const errorCode = error => /^IRIS_NETWORK_/.test(error.code || '') || ['IRIS_CONFIG_CHANGED', 'IRIS_CONFIG_BUSY', 'IRIS_PROVIDER_OPERATION_UNSUPPORTED'].includes(error.code) ? error.code
9
10
  : ['authentication', 'permission', 'network', 'timeout', 'protocol', 'rate_limit', 'provider', 'aborted'].includes(error.category) ? error.category : 'connection_failed';
10
11
  const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
11
- export function createProviderMonitor({ configPath, registry, setTimer = setTimeout, clearTimer = clearTimeout }) {
12
+ export function createProviderMonitor({ configPath, registry, resolveProviderNetwork, setTimer = setTimeout, clearTimer = clearTimeout }) {
12
13
  const records = new Map(), pending = new Set(); let closed = false;
13
14
  const catalog = () => { if (closed) fail('IRIS_PROVIDER_MONITOR_CLOSED', '连接检查已关闭'); return loadProviderCatalog(configPath); };
14
15
  function record(providerId, provider = catalog().providers.find(item => item.id === providerId)) {
@@ -31,13 +32,14 @@ export function createProviderMonitor({ configPath, registry, setTimer = setTime
31
32
  }
32
33
  async function check(providerId, expectedRevision, { signal } = {}) {
33
34
  const { provider, item } = record(providerId);
35
+ if (provider.enabled !== false) assertProviderCredential(provider);
34
36
  if (!isConfiguredProvider(provider)) fail('IRIS_CONFIG_INPUT_INVALID', '请先保存并启用供应商连接');
35
37
  if (item.pending) fail('IRIS_API_BUSY', '此供应商正在检查,请等待结果');
36
38
  const controller = item.controller = new AbortController();
37
39
  const started = Date.now();
38
40
  const job = (async () => {
39
41
  try {
40
- const result = await executeConfigCommand(configPath, 'settings.discover', { expectedRevision, providerId }, { registry,
42
+ const result = await executeConfigCommand(configPath, 'settings.discover', { expectedRevision, providerId }, { registry, resolveProviderNetwork,
41
43
  signal: AbortSignal.any([controller.signal, ...(signal ? [signal] : [])]) });
42
44
  item.last = { ok: true, at: new Date().toISOString(), elapsedMs: Date.now() - started, modelCount: result.models.length };
43
45
  } catch (error) {