@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
@@ -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
 
@@ -21,8 +21,15 @@ export function recordModelAvailability(provider, modelId, capability, result) {
21
21
  if (!modelId || !isKnownCapability(cap)) return false;
22
22
  let observation = provider.health?.observations?.find(item => item.modelId === modelId && item.capability === cap);
23
23
  const blocked = isModelNotFound(result);
24
+ // A real accepted/completed submission is evidence for this exact connection; directory reads never enter here.
25
+ if (result.ok === true && result.stage === 'submit') {
26
+ provider.health ||= { version: 1, revision: 0, observations: [] };
27
+ provider.health.observations ||= [];
28
+ if (!observation) { observation = { modelId, capability: cap }; provider.health.observations.push(observation); }
29
+ observation.lastSuccess = { at: result.at || new Date().toISOString(), source: result.source === 'probe' ? 'probe' : 'task', category: 'success' };
30
+ }
24
31
  if (!blocked && !(result.ok === true && (result.source === 'probe' || result.stage === 'submit')
25
- && (observation?.availability?.state === 'blocked' || modelUnavailable(provider, modelId, cap)))) return false;
32
+ && (observation?.availability?.state === 'blocked' || modelUnavailable(provider, modelId, cap)))) return result.ok === true && result.stage === 'submit';
26
33
  provider.health ||= { version: 1, revision: 0, observations: [] };
27
34
  provider.health.observations ||= [];
28
35
  observation ||= provider.health.observations.find(item => item.modelId === modelId && item.capability === cap);
@@ -44,14 +51,18 @@ export const PROVIDER_HEALTH_STATES = Object.freeze([
44
51
 
45
52
  const DEFINITIVE_FAILURES = new Set(['authentication', 'auth', 'permission']);
46
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));
47
58
 
48
59
  export function retryAfterDelay(value, now = Date.now()) {
49
60
  const raw = String(value ?? '').trim();
50
61
  if (!raw) return MODEL_RATE_LIMIT_COOLDOWN_MS;
51
62
  const seconds = Number(raw);
52
- if (Number.isFinite(seconds) && seconds >= 0) return Math.max(1000, seconds * 1000);
63
+ if (Number.isFinite(seconds) && seconds >= 0) return boundedCooldown(seconds * 1000);
53
64
  const at = Date.parse(raw);
54
- 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;
55
66
  }
56
67
 
57
68
  /** 百炼“免费额度用完即停”返回 403,不应误归类为 Key 无效。 */
@@ -87,12 +98,13 @@ export function recordModelRateLimit(provider, modelId, result) {
87
98
  provider.health.rateLimits ||= [];
88
99
  const at = Date.parse(result.at || '');
89
100
  const timestamp = Number.isFinite(at) ? at : Date.now();
90
- 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;
91
103
  provider.health.rateLimits = provider.health.rateLimits.filter(item => item.modelId !== modelId);
92
104
  provider.health.rateLimits.push({ modelId,
93
105
  at: new Date(timestamp).toISOString(),
94
106
  source: result.source === 'probe' ? 'probe' : 'task', category, httpStatus: status,
95
- ...(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() }) });
96
108
  return true;
97
109
  }
98
110
 
@@ -0,0 +1,81 @@
1
+ // Optional, service-owned, read-only connection checks. Never a media probe or durable health assertion.
2
+ import { loadProviderCatalog } from './provider-catalog.js';
3
+ import { executeConfigCommand } from './provider-config-service.js';
4
+ import { modelDirectoryBinding } from './model-directory.js';
5
+ import { selectMediaProtocol, isConfiguredProvider } from './provider-protocol.js';
6
+ import { assertProviderCredential } from './provider-credentials.js';
7
+
8
+ const AUTOMATIC = new Set(['dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images', 'gemini-web-music', 'comfyui']);
9
+ const errorCode = error => /^IRIS_NETWORK_/.test(error.code || '') || ['IRIS_CONFIG_CHANGED', 'IRIS_CONFIG_BUSY', 'IRIS_PROVIDER_OPERATION_UNSUPPORTED'].includes(error.code) ? error.code
10
+ : ['authentication', 'permission', 'network', 'timeout', 'protocol', 'rate_limit', 'provider', 'aborted'].includes(error.category) ? error.category : 'connection_failed';
11
+ const fail = (code, message) => { throw Object.assign(new Error(message), { code }); };
12
+ export function createProviderMonitor({ configPath, registry, resolveProviderNetwork, setTimer = setTimeout, clearTimer = clearTimeout }) {
13
+ const records = new Map(), pending = new Set(); let closed = false;
14
+ const catalog = () => { if (closed) fail('IRIS_PROVIDER_MONITOR_CLOSED', '连接检查已关闭'); return loadProviderCatalog(configPath); };
15
+ function record(providerId, provider = catalog().providers.find(item => item.id === providerId)) {
16
+ if (!provider) fail('IRIS_CONFIG_INPUT_INVALID', '供应商不存在');
17
+ let item = records.get(providerId);
18
+ if (item && item.binding !== modelDirectoryBinding(provider)) { stop(item); records.delete(providerId); item = null; }
19
+ if (!item) { item = { providerId, binding: modelDirectoryBinding(provider), enabled: false, intervalSeconds: 300, last: null, epoch: 0 }; records.set(providerId, item); }
20
+ if (!isConfiguredProvider(provider)) stop(item);
21
+ return { provider, item };
22
+ }
23
+ function stop(item) { item.epoch++; clearTimer(item.timer); item.enabled = false; item.controller?.abort(); }
24
+ function snapshot() {
25
+ const providers = catalog().providers.map(provider => {
26
+ const { item } = record(provider.id, provider);
27
+ return { providerId: provider.id, automaticSupported: AUTOMATIC.has(selectMediaProtocol(provider).mediaProtocol),
28
+ enabled: item.enabled, intervalSeconds: item.intervalSeconds, checking: Boolean(item.pending), last: item.last };
29
+ });
30
+ for (const [id, item] of records) if (!providers.some(provider => provider.providerId === id)) { stop(item); records.delete(id); }
31
+ return providers;
32
+ }
33
+ async function check(providerId, expectedRevision, { signal } = {}) {
34
+ const { provider, item } = record(providerId);
35
+ if (provider.enabled !== false) assertProviderCredential(provider);
36
+ if (!isConfiguredProvider(provider)) fail('IRIS_CONFIG_INPUT_INVALID', '请先保存并启用供应商连接');
37
+ if (item.pending) fail('IRIS_API_BUSY', '此供应商正在检查,请等待结果');
38
+ const controller = item.controller = new AbortController();
39
+ const started = Date.now();
40
+ const job = (async () => {
41
+ try {
42
+ const result = await executeConfigCommand(configPath, 'settings.discover', { expectedRevision, providerId }, { registry, resolveProviderNetwork,
43
+ signal: AbortSignal.any([controller.signal, ...(signal ? [signal] : [])]) });
44
+ item.last = { ok: true, at: new Date().toISOString(), elapsedMs: Date.now() - started, modelCount: result.models.length };
45
+ } catch (error) {
46
+ if (error.code === 'IRIS_CONFIG_CHANGED') throw error;
47
+ item.last = { ok: false, at: new Date().toISOString(), elapsedMs: Date.now() - started,
48
+ code: controller.signal.aborted || signal?.aborted ? 'check_unconfirmed' : errorCode(error),
49
+ ...(Number.isInteger(error.httpStatus) ? { httpStatus: error.httpStatus } : {}) };
50
+ }
51
+ return { contractVersion: 0, providerId, ...item.last };
52
+ })();
53
+ item.pending = job;
54
+ pending.add(job);
55
+ try { return await job; } finally { pending.delete(job); item.pending = null; item.controller = null; }
56
+ }
57
+ function schedule(item) {
58
+ const epoch = item.epoch;
59
+ item.timer = setTimer(async () => {
60
+ if (!item.enabled || closed || epoch !== item.epoch) return;
61
+ try {
62
+ const view = await executeConfigCommand(configPath, 'settings.show', {}, { registry });
63
+ const { provider, item: current } = record(item.providerId);
64
+ if (current !== item || !item.enabled || !isConfiguredProvider(provider)) return;
65
+ if (!item.pending) await check(item.providerId, view.revision);
66
+ } catch (error) { item.last = { ok: false, at: new Date().toISOString(), code: errorCode(error) }; }
67
+ finally { if (item.enabled && !closed && epoch === item.epoch) schedule(item); }
68
+ }, item.intervalSeconds * 1000);
69
+ item.timer?.unref?.();
70
+ }
71
+ function configure({ providerId, enabled, intervalSeconds }) {
72
+ if (typeof enabled !== 'boolean' || ![60, 300, 900].includes(intervalSeconds)) fail('IRIS_CONFIG_INPUT_INVALID', '保活需选择开关和60/300/900秒间隔');
73
+ const { provider, item } = record(providerId);
74
+ if (enabled && (!isConfiguredProvider(provider) || !AUTOMATIC.has(selectMediaProtocol(provider).mediaProtocol))) fail('IRIS_CONFIG_INPUT_INVALID', '此连接没有已支持的自动轻量检查');
75
+ stop(item); item.intervalSeconds = intervalSeconds; item.enabled = enabled;
76
+ if (enabled) schedule(item);
77
+ return { contractVersion: 0, monitors: snapshot(), persistence: 'service-session' };
78
+ }
79
+ async function dispose() { closed = true; for (const item of records.values()) stop(item); await Promise.allSettled([...pending]); records.clear(); }
80
+ return Object.freeze({ snapshot, check, configure, dispose });
81
+ }
@@ -0,0 +1,52 @@
1
+ // Internal composition port. No proxy configuration, global mutation or automatic retry.
2
+ export function inheritedProviderFetch(...args) { return globalThis.fetch(...args); }
3
+
4
+ function closed() {
5
+ return Object.assign(new Error('Provider 网络客户端正在关闭或已关闭'), {
6
+ code: 'IRIS_PROVIDER_NETWORK_CLOSED', stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
7
+ });
8
+ }
9
+
10
+ export function assertProviderNetwork(network) {
11
+ if (!network || typeof network.fetch !== 'function' || typeof network.run !== 'function') {
12
+ throw new TypeError('network 需要实例级 fetch 和完整操作 run');
13
+ }
14
+ return network;
15
+ }
16
+
17
+ /** The owner drains Core/registry operations before disposing this client. */
18
+ export function createProviderNetworkClient({ fetchPort, close } = {}) {
19
+ if (fetchPort !== undefined && typeof fetchPort !== 'function') throw new TypeError('fetchPort 必须是函数');
20
+ if (close !== undefined && typeof close !== 'function') throw new TypeError('close 必须是函数');
21
+ const active = new Set();
22
+ let state = 'open', disposal, cleanupFailed = false;
23
+ const snapshot = () => Object.freeze({ state, activeOperations: active.size, cleanupFailed });
24
+
25
+ // Used inside run: closing must still permit later pages, uploads and body reads.
26
+ const fetch = (...args) => {
27
+ if (state === 'closed') return Promise.reject(closed());
28
+ return (fetchPort || inheritedProviderFetch)(...args);
29
+ };
30
+ function run(action) {
31
+ if (typeof action !== 'function') throw new TypeError('网络操作必须是函数');
32
+ if (state !== 'open') return Promise.reject(closed());
33
+ const operation = Promise.resolve().then(() => action(fetch));
34
+ active.add(operation);
35
+ void operation.then(() => active.delete(operation), () => active.delete(operation));
36
+ return operation;
37
+ }
38
+ function dispose() {
39
+ if (!disposal) {
40
+ state = 'closing';
41
+ disposal = (async () => {
42
+ await Promise.allSettled([...active]);
43
+ try { await close?.(); }
44
+ catch (error) { cleanupFailed = true; throw error; }
45
+ finally { state = 'closed'; }
46
+ return snapshot();
47
+ })();
48
+ }
49
+ return disposal;
50
+ }
51
+ return Object.freeze({ fetch, run, dispose, snapshot });
52
+ }
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
  /** 供应商媒体协议判断与 DashScope 凭据边界。 */
3
+ import { comfyWorkflowDigest } from './comfyui-workflow.js';
4
+ import { providerCredentialValue, providerCredentialStatus } from './provider-credentials.js';
3
5
 
4
6
  const DASH_SCOPE_HOST = /^dashscope(?:-[a-z0-9-]+)?\.aliyuncs\.com$/i;
5
7
  const REGIONAL_HOST = /^cn-hongkong\.dashscope\.aliyuncs\.com$/i;
@@ -46,44 +48,54 @@ export function selectMediaProtocol(provider) {
46
48
  }
47
49
 
48
50
  /** 模型图片协议只覆盖生图;auto 沿用账号级默认,不另外猜测端点。 */
49
- export function normalizeImageProtocol(value) {
51
+ export function normalizeImageProtocol(value, registry) {
50
52
  const protocol = typeof value === 'string' ? value.trim() : '';
51
- if (!['auto', 'dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images'].includes(protocol)) {
52
- throw Object.assign(new TypeError('imageProtocol 必须是 auto、dashscope、openai-images、openai-chat-images 或 openai-responses-images'), {
53
+ if (protocol !== 'auto' && !providerProtocolDescriptor(protocol, registry)?.capabilities.includes('image-gen')) {
54
+ throw Object.assign(new TypeError('imageProtocol 必须是 auto 或当前实例已注册的图片协议'), {
53
55
  code: 'IRIS_PROVIDER_IMAGE_PROTOCOL_INVALID'
54
56
  });
55
57
  }
56
58
  return protocol;
57
59
  }
58
60
 
59
- export function selectImageProtocol(provider, modelId) {
61
+ export function selectImageProtocol(provider, modelId, registry) {
60
62
  const entry = Array.isArray(provider?.models) ? provider.models.find(model => model?.id?.trim() === modelId) : null;
61
63
  if (entry?.imageProtocol === undefined) return selectMediaProtocol(provider);
62
- const protocol = normalizeImageProtocol(entry.imageProtocol);
64
+ const protocol = normalizeImageProtocol(entry.imageProtocol, registry);
63
65
  return protocol === 'auto' ? selectMediaProtocol(provider) : { mediaProtocol: protocol, protocolInferred: false };
64
66
  }
65
67
 
68
+ /** Binding/health reads preserve an explicitly saved protocol even if its extension is absent. */
69
+ export function providerForModelBinding(provider, modelId, capability) {
70
+ const entry = provider.models?.find(model => model?.id?.trim() === modelId);
71
+ const value = capability === 'music-gen' ? entry?.musicProtocol : entry?.imageProtocol;
72
+ const protocol = typeof value === 'string' ? value.trim() : '';
73
+ return { ...provider, ...(protocol && protocol !== 'auto'
74
+ ? { mediaProtocol: protocol, protocolInferred: false } : selectMediaProtocol(provider)) };
75
+ }
76
+
66
77
  /** 已接入的带图编辑协议;这里只判断传输,不授予模型 image-gen 能力。 */
67
- export function supportsImageEdit(protocol, modelId) {
68
- return protocol === 'openai-chat-images' || (protocol === 'dashscope'
69
- && /^qwen-image-(?:edit(?:-(?:max|plus))?|2\.0(?:-pro)?)(?:-\d{4}-\d{2}-\d{2})?$/.test(String(modelId || '')));
78
+ export function supportsImageEdit(protocol, modelId, registry) {
79
+ return providerProtocolDescriptor(protocol, registry)?.supportsImageEdit?.(modelId) === true;
70
80
  }
71
81
 
72
82
  /** 给已绑定图片模型的 Adapter/binding 投影协议,不改写账号默认值。 */
73
- export function providerForImageModel(provider, modelId) {
74
- return { ...provider, ...selectImageProtocol(provider, modelId) };
83
+ export function providerForImageModel(provider, modelId, registry) {
84
+ const selection = selectImageProtocol(provider, modelId, registry);
85
+ return { ...provider, ...selection, ...(selection.mediaProtocol === 'comfyui'
86
+ ? { comfyWorkflow: provider.models?.find(model => model?.id === modelId)?.comfyWorkflow } : {}) };
75
87
  }
76
88
 
77
- export function normalizeMusicProtocol(value) {
78
- if (value !== 'gemini-web-music') throw Object.assign(new TypeError('musicProtocol 必须是 gemini-web-music'), {
89
+ export function normalizeMusicProtocol(value, registry) {
90
+ if (!providerProtocolDescriptor(value, registry)?.capabilities.includes('music-gen')) throw Object.assign(new TypeError('musicProtocol 必须是已注册的音乐协议'), {
79
91
  code: 'IRIS_PROVIDER_MUSIC_PROTOCOL_INVALID'
80
92
  });
81
93
  return value;
82
94
  }
83
95
 
84
- export function providerForMusicModel(provider, modelId) {
96
+ export function providerForMusicModel(provider, modelId, registry) {
85
97
  const entry = provider.models?.find(model => model?.id?.trim() === modelId);
86
- return { ...provider, mediaProtocol: normalizeMusicProtocol(entry?.musicProtocol), protocolInferred: false };
98
+ return { ...provider, mediaProtocol: normalizeMusicProtocol(entry?.musicProtocol, registry), protocolInferred: false };
87
99
  }
88
100
 
89
101
  export function unsupportedProtocolError(protocol, kind = 'media') {
@@ -103,7 +115,7 @@ export function dashscopeApiBase(baseUrl) {
103
115
 
104
116
  /** 无认证模式不发送保留的旧凭据;未声明模式保持 bearer 兼容。 */
105
117
  export function providerApiKey(provider) {
106
- return provider?.auth === 'none' ? '' : String(provider?.apiKey || '');
118
+ return provider?.auth === 'none' ? '' : providerCredentialValue(provider);
107
119
  }
108
120
 
109
121
  /** 配置管理与 headless 候选链共用可用性门,未声明 enabled 时默认启用。 */
@@ -111,7 +123,7 @@ export function isConfiguredProvider(provider) {
111
123
  return Boolean(provider && provider.enabled !== false
112
124
  && typeof provider.id === 'string' && provider.id.trim()
113
125
  && providerMediaBaseUrl(provider)
114
- && (provider.auth === 'none' || (typeof provider.apiKey === 'string' && provider.apiKey.trim())));
126
+ && (provider.auth === 'none' || providerCredentialStatus(provider) === 'ready'));
115
127
  }
116
128
 
117
129
  /** 已知需要其他接口的语音系列;自定义模型仍以明确配置和实测为准。 */
@@ -135,13 +147,52 @@ export function dashscopeTtsInputIssue(modelId, voice) {
135
147
  }
136
148
 
137
149
  /** Protocol support is independent from model capability declarations and health. */
138
- export function supportsModelCapability(provider, modelId, capability, { checkEndpoint = true } = {}) {
150
+ export function supportsModelCapability(provider, modelId, capability, { checkEndpoint = true, registry } = {}) {
139
151
  if (capability === 'vision') return true;
140
- if (capability === 'music-gen') return provider?.models?.some(model => model?.id === modelId && model.musicProtocol === 'gemini-web-music') === true;
141
- const protocol = capability === 'image-gen' ? selectImageProtocol(provider, modelId).mediaProtocol : selectMediaProtocol(provider).mediaProtocol;
142
- if (protocol === 'dashscope') return (capability !== 'tts' || !dashscopeTtsProtocolIssue(modelId))
143
- && (!checkEndpoint || isDashScopeBaseUrl(providerMediaBaseUrl(provider)));
144
- return capability === 'image-gen' && ['openai-images', 'openai-chat-images', 'openai-responses-images'].includes(protocol);
152
+ const protocol = capability === 'music-gen' ? provider?.models?.find(model => model?.id === modelId)?.musicProtocol
153
+ : capability === 'image-gen' ? selectImageProtocol(provider, modelId, registry).mediaProtocol : selectMediaProtocol(provider).mediaProtocol;
154
+ const descriptor = providerProtocolDescriptor(protocol, registry);
155
+ return Boolean(descriptor?.capabilities.includes(capability)
156
+ && (!descriptor.supportsModel || descriptor.supportsModel({ provider, modelId, capability, checkEndpoint })));
157
+ }
158
+
159
+ /** Canonical binding spelling; existing Task hashes depend on this exact normalization. */
160
+ export function normalizeMediaEndpoint(baseUrl) {
161
+ const url = new URL(baseUrl);
162
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
163
+ throw new TypeError('媒体端点必须是无凭据、查询或片段的 HTTP(S) 地址');
164
+ }
165
+ return url.href.replace(/\/+$/, '');
166
+ }
167
+
168
+ const CONNECTION_FIELDS = Object.freeze([
169
+ Object.freeze({ key: 'baseUrl', label: 'API 地址', type: 'url', required: true }),
170
+ Object.freeze({ key: 'mediaBaseUrl', label: '媒体专用地址', type: 'url', advanced: true }),
171
+ Object.freeze({ key: 'apiKey', label: 'API Key', type: 'secret' })
172
+ ]);
173
+
174
+ /** Immutable defaults, not a mutable process-wide extension registry. */
175
+ export const BUILTIN_PROVIDER_PROTOCOLS = Object.freeze([
176
+ { id: 'gemini-web-music', label: 'Chat Completions(内嵌音频)', capabilities: ['music-gen'] },
177
+ { id: 'dashscope', label: 'DashScope', capabilities: ['image-gen', 'video-gen', 'tts', 'transcribe'],
178
+ normalizeEndpoint: dashscopeApiBase,
179
+ supportsModel: ({ provider, modelId, capability, checkEndpoint }) => (capability !== 'tts' || !dashscopeTtsProtocolIssue(modelId))
180
+ && (!checkEndpoint || isDashScopeBaseUrl(providerMediaBaseUrl(provider))),
181
+ supportsImageEdit: modelId => /^qwen-image-(?:edit(?:-(?:max|plus))?|2\.0(?:-pro)?)(?:-\d{4}-\d{2}-\d{2})?$/.test(String(modelId || '')) },
182
+ { id: 'openai-images', label: 'OpenAI Images', capabilities: ['image-gen'] },
183
+ { id: 'openai-chat-images', label: 'Chat Completions 图片', capabilities: ['image-gen'], supportsImageEdit: () => true },
184
+ { id: 'openai-responses-images', label: 'Responses 图片', capabilities: ['image-gen'] },
185
+ { id: 'comfyui', label: 'ComfyUI · API 工作流', capabilities: ['image-gen'],
186
+ supportsModel: ({ provider, modelId }) => {
187
+ const value = provider.models?.find(model => model?.id === modelId)?.comfyWorkflow;
188
+ if (!value) return false;
189
+ comfyWorkflowDigest(value); return true;
190
+ } }
191
+ ].map(descriptor => Object.freeze({ normalizeEndpoint: normalizeMediaEndpoint, configuration: CONNECTION_FIELDS,
192
+ ...descriptor, capabilities: Object.freeze(descriptor.capabilities) })));
193
+
194
+ export function providerProtocolDescriptor(protocol, registry) {
195
+ return registry ? registry.descriptor(protocol) : BUILTIN_PROVIDER_PROTOCOLS.find(descriptor => descriptor.id === protocol);
145
196
  }
146
197
 
147
198
  /**
@@ -0,0 +1,128 @@
1
+ import { defineProviderAdapter } from './provider-adapter.js';
2
+ import { unsupportedProtocolError, providerApiKey } from './provider-protocol.js';
3
+ import { checkedPluginDependency, assertPluginDependencyMatches } from './plugin-contract.js';
4
+ import { createProviderNetworkClient } from './provider-network.js';
5
+ import { createProviderContext, markProviderContextAdapter } from './provider-context.js';
6
+ import { resolveProviderCredential } from './provider-credentials.js';
7
+
8
+ export const PROVIDER_EXTENSION_CONTRACT_VERSION = 0;
9
+ const ID = /^[a-z][a-z0-9._-]{0,79}$/;
10
+ const CAPS = Object.freeze({ 'image-gen': 'image', 'video-gen': 'video', tts: 'tts', 'music-gen': 'music', transcribe: 'transcribe' });
11
+ const invalid = message => Object.assign(new TypeError(message), { code: 'IRIS_PROVIDER_EXTENSION_INVALID' });
12
+ const closed = () => Object.assign(new Error('Provider 扩展实例已关闭或正在关闭'), {
13
+ code: 'IRIS_PROVIDER_EXTENSION_CLOSED', stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted' });
14
+
15
+ function checkedProtocol(value) {
16
+ if (!value || typeof value.id !== 'string' || !ID.test(value.id) || typeof value.label !== 'string' || !value.label.trim() || value.label.length > 100
17
+ || !Array.isArray(value.capabilities) || !value.capabilities.length
18
+ || value.capabilities.some(cap => !Object.hasOwn(CAPS, cap)) || new Set(value.capabilities).size !== value.capabilities.length
19
+ || typeof value.createAdapter !== 'function' || typeof value.normalizeEndpoint !== 'function'
20
+ || (value.supportsModel !== undefined && typeof value.supportsModel !== 'function')
21
+ || (value.supportsImageEdit !== undefined && typeof value.supportsImageEdit !== 'function')) throw invalid('Provider 协议描述无效');
22
+ if (value.configuration !== undefined && !Array.isArray(value.configuration)) throw invalid('配置字段描述必须是数组');
23
+ const keys = new Set();
24
+ const configuration = (value.configuration || []).map(field => {
25
+ if (!field || !['baseUrl', 'mediaBaseUrl', 'apiKey'].includes(field.key) || keys.has(field.key)
26
+ || typeof field.label !== 'string' || !field.label.trim() || field.label.length > 100
27
+ || field.type !== (field.key === 'apiKey' ? 'secret' : 'url')
28
+ || (field.required !== undefined && typeof field.required !== 'boolean')
29
+ || (field.advanced !== undefined && typeof field.advanced !== 'boolean')) throw invalid('Provider 配置字段描述无效');
30
+ keys.add(field.key);
31
+ return Object.freeze({ key: field.key, label: field.label, type: field.type,
32
+ ...(field.required !== undefined ? { required: field.required } : {}), ...(field.advanced !== undefined ? { advanced: field.advanced } : {}) });
33
+ });
34
+ const dependency = value.dependency === undefined ? undefined : checkedPluginDependency(value.dependency);
35
+ if (dependency && (dependency.kind !== 'providers' || dependency.contributionId !== value.id)) throw invalid('Provider 与插件贡献依赖不一致');
36
+ return Object.freeze({ id: value.id, label: value.label, capabilities: Object.freeze([...value.capabilities]),
37
+ configuration: Object.freeze(configuration), normalizeEndpoint: value.normalizeEndpoint,
38
+ ...(dependency ? { dependency } : {}),
39
+ ...(value.supportsModel ? { supportsModel: value.supportsModel } : {}),
40
+ ...(value.supportsImageEdit ? { supportsImageEdit: value.supportsImageEdit } : {}), createAdapter: value.createAdapter });
41
+ }
42
+
43
+ /** One registry owns its factory instances, active calls and serial asynchronous cleanup. */
44
+ export async function createExtensionRegistry(factories) {
45
+ const protocols = new Map(), extensions = [], active = new Set();
46
+ let state = 'loading', disposal, cleanupFailed = false, inherited;
47
+ const inheritedNetwork = () => inherited ||= createProviderNetworkClient();
48
+
49
+ function dispose() {
50
+ if (!disposal) {
51
+ state = 'closing';
52
+ disposal = (async () => {
53
+ await Promise.allSettled([...active]);
54
+ const errors = [];
55
+ try { await inherited?.dispose(); } catch (error) { errors.push(error); }
56
+ for (const extension of [...extensions].reverse()) {
57
+ try { await extension.dispose?.(); } catch (error) { errors.push(error); }
58
+ }
59
+ protocols.clear(); state = 'closed'; cleanupFailed = errors.length > 0;
60
+ if (errors.length) throw new AggregateError(errors, 'Provider 扩展释放失败');
61
+ })();
62
+ }
63
+ return disposal;
64
+ }
65
+
66
+ try {
67
+ for (const factory of factories) {
68
+ if (typeof factory !== 'function') throw invalid('扩展入口必须是创建独立实例的工厂函数');
69
+ const extension = await factory();
70
+ if (extension && typeof extension.dispose === 'function') extensions.push({ dispose: () => extension.dispose() });
71
+ if (!extension || extension.contractVersion !== PROVIDER_EXTENSION_CONTRACT_VERSION || typeof extension.id !== 'string' || !ID.test(extension.id)
72
+ || typeof extension.version !== 'string' || !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(extension.version)
73
+ || !Array.isArray(extension.protocols) || !extension.protocols.length
74
+ || (extension.dispose !== undefined && typeof extension.dispose !== 'function')) throw invalid('扩展身份、版本或协议集合无效');
75
+ if (extensions.some(item => item.id === extension.id)) throw invalid('扩展 ID 重复:' + extension.id);
76
+ const owned = extension.protocols.map(checkedProtocol);
77
+ for (const protocol of owned) {
78
+ if (protocol.dependency && (protocol.dependency.pluginId !== extension.id || protocol.dependency.pluginVersion !== extension.version)) throw invalid('Provider 依赖不属于当前扩展版本');
79
+ if (protocols.has(protocol.id)) throw invalid('Provider 协议重复:' + protocol.id);
80
+ protocols.set(protocol.id, protocol);
81
+ }
82
+ if (typeof extension.dispose === 'function') Object.assign(extensions[extensions.length - 1], { id: extension.id, version: extension.version });
83
+ else extensions.push({ id: extension.id, version: extension.version });
84
+ }
85
+ state = 'active';
86
+ } catch (error) {
87
+ try { await dispose(); } catch (cleanup) { throw new AggregateError([error, cleanup], 'Provider 扩展装配与释放均失败'); }
88
+ throw error;
89
+ }
90
+
91
+ const wrap = action => async (...args) => {
92
+ if (state !== 'active') throw closed();
93
+ const call = Promise.resolve().then(() => action(...args));
94
+ active.add(call);
95
+ try { return await call; } finally { active.delete(call); }
96
+ };
97
+
98
+ return Object.freeze({
99
+ descriptor: id => state === 'active' ? protocols.get(id) : undefined,
100
+ snapshot: () => ({ contractVersion: PROVIDER_EXTENSION_CONTRACT_VERSION, state, activeCalls: active.size, cleanupFailed,
101
+ extensions: extensions.filter(item => item.id).map(({ id, version }) => ({ id, version })),
102
+ protocols: [...protocols.values()].map(({ id, label, capabilities, configuration, dependency }) => ({ id, label,
103
+ capabilities: [...capabilities], configuration: configuration.map(field => ({ ...field })),
104
+ ...(dependency ? { dependency: { ...dependency } } : {}) })) }),
105
+ createAdapter(id, provider, options) {
106
+ if (state !== 'active') throw closed();
107
+ const descriptor = protocols.get(id);
108
+ if (!descriptor) throw unsupportedProtocolError(id);
109
+ const author = descriptor.dependency ? createProviderContext(provider, options, inheritedNetwork) : undefined;
110
+ const resolved = author ? resolveProviderCredential(provider) : provider;
111
+ const connection = { ...resolved, apiKey: providerApiKey(resolved) };
112
+ const value = descriptor.createAdapter(author ? Object.freeze(connection) : connection, author?.context || options);
113
+ if (!value || typeof value.then === 'function') throw invalid('createAdapter 必须同步返回 Adapter');
114
+ if (value.pluginDependency !== undefined) assertPluginDependencyMatches(descriptor.dependency, value.pluginDependency);
115
+ const adapter = defineProviderAdapter({ ...value,
116
+ ...(descriptor.dependency ? { pluginDependency: descriptor.dependency } : {}),
117
+ unsupported: Object.fromEntries(Object.entries(value.unsupported || {}).map(([name, reason]) => [name, reason?.reason ?? reason])) });
118
+ if (adapter.id !== provider.id || adapter.protocol !== id || adapter.capabilities.some(cap =>
119
+ !descriptor.capabilities.some(known => CAPS[known] === cap))) throw invalid('Adapter 身份或能力与注册协议不一致');
120
+ const operation = (name, action) => wrap(author ? (...args) => author.invoke(name, action, args) : action);
121
+ const result = Object.freeze({ ...adapter, operations: Object.freeze(Object.fromEntries(Object.entries(adapter.operations)
122
+ .map(([name, action]) => [name, name === 'mapError' ? action : operation(name, action)]))),
123
+ ...(adapter.prepareInput ? { prepareInput: operation('prepareInput', adapter.prepareInput) } : {}) });
124
+ return author ? markProviderContextAdapter(result) : result;
125
+ },
126
+ dispose
127
+ });
128
+ }
@@ -0,0 +1,38 @@
1
+ import { ProviderContractError } from './provider-contract.js';
2
+
3
+ export const PROVIDER_RESPONSE_LIMITS = Object.freeze({ json: 2 * 1024 * 1024, mediaJson: 128 * 1024 * 1024,
4
+ image: 64 * 1024 * 1024, audio: 128 * 1024 * 1024, transcript: 8 * 1024 * 1024, video: 1024 * 1024 * 1024 });
5
+
6
+ /** Core materialization uses the same limits as provider downloads, including cached bytes. */
7
+ export function assertProviderMediaSize(size, capability) {
8
+ const maxBytes = { image: PROVIDER_RESPONSE_LIMITS.image, video: PROVIDER_RESPONSE_LIMITS.video,
9
+ tts: PROVIDER_RESPONSE_LIMITS.audio, music: PROVIDER_RESPONSE_LIMITS.audio, transcribe: PROVIDER_RESPONSE_LIMITS.transcript }[capability];
10
+ if (maxBytes === undefined) throw new TypeError('未知的媒体交付能力:' + capability);
11
+ if (size > maxBytes) throw Object.assign(new ProviderContractError('供应商媒体超过允许的大小(' + maxBytes + ' 字节)', {
12
+ stage: 'download', category: 'protocol', acceptance: 'accepted', retryable: false
13
+ }), { code: 'IRIS_PROVIDER_RESPONSE_TOO_LARGE' });
14
+ }
15
+
16
+ /** Count decoded response bytes as well as Content-Length: chunked and compressed
17
+ * responses cannot bypass the limit. Iterator cancellation closes the body. */
18
+ export async function* boundedProviderBody(response, { maxBytes, stage = 'response', acceptance = 'unknown' }) {
19
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new TypeError('供应商响应限额须为正整数字节数');
20
+ const tooLarge = () => Object.assign(new ProviderContractError('供应商响应超过允许的大小(' + maxBytes + ' 字节)', {
21
+ stage, category: 'protocol', acceptance, retryable: false
22
+ }), { code: 'IRIS_PROVIDER_RESPONSE_TOO_LARGE' });
23
+ if (Number(response.headers.get('content-length')) > maxBytes) {
24
+ try { await response.body?.cancel(); } finally { throw tooLarge(); }
25
+ }
26
+ let size = 0;
27
+ if (response.body) for await (const chunk of response.body) {
28
+ size += chunk.length;
29
+ if (size > maxBytes) throw tooLarge();
30
+ yield chunk;
31
+ }
32
+ }
33
+
34
+ export async function readProviderText(response, options) {
35
+ const chunks = []; let size = 0;
36
+ for await (const chunk of boundedProviderBody(response, options)) { chunks.push(chunk); size += chunk.length; }
37
+ return Buffer.concat(chunks, size).toString('utf8');
38
+ }