@mokuyoaxis/dsh-iris 0.1.4 → 0.2.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 (110) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +10 -5
  2. package/.dsh/skills/iris-compose-media/references/workflows.md +7 -1
  3. package/.dsh/skills/iris-verify-ui/SKILL.md +2 -1
  4. package/CHANGELOG.md +99 -1
  5. package/README.en.md +124 -31
  6. package/README.md +124 -31
  7. package/bin/dsh-iris.js +593 -13
  8. package/docs/AGENT_QUICKSTART.md +39 -0
  9. package/docs/ARCHITECTURE.md +87 -64
  10. package/docs/ARTIFACT_LIBRARY.md +5 -5
  11. package/docs/ARTIFACT_MANIFEST.md +60 -0
  12. package/docs/CLI_MANAGEMENT.md +181 -0
  13. package/docs/COMPOSITE_VISION.md +35 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +84 -0
  15. package/docs/DSH_CORE_MIGRATION.md +135 -0
  16. package/docs/DSH_RC2_ADAPTATION.md +38 -0
  17. package/docs/FAULT_INJECTION.md +12 -2
  18. package/docs/HEADLESS_CLI.md +223 -0
  19. package/docs/HOST_ADAPTER_CONTRACT.md +36 -17
  20. package/docs/HOST_DOCTOR.md +4 -2
  21. package/docs/MODEL_PORT_CONTRACT.md +305 -0
  22. package/docs/OCR_MODEL.md +39 -0
  23. package/docs/PROMPT_OPTIMIZER.md +47 -0
  24. package/docs/PROVIDER_ADAPTER_CONTRACT.md +53 -10
  25. package/docs/PROVIDER_HEALTH.md +22 -5
  26. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  27. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  28. package/docs/ROADMAP.md +49 -83
  29. package/docs/SECURITY.md +8 -7
  30. package/docs/TASK_SEMANTICS.md +2 -2
  31. package/docs/VISION_CLI.md +132 -0
  32. package/docs/VISION_MODEL.md +51 -0
  33. package/docs/WORKBENCH_ARTIFACTS.md +67 -0
  34. package/docs/assets/logo/README.md +55 -0
  35. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  36. package/docs/assets/logo/iris-bubble.svg +14 -0
  37. package/docs/assets/logo/iris-interim-flower.png +0 -0
  38. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  39. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  40. package/docs/assets/logo/iris-logo.svg +78 -0
  41. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  42. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  43. package/docs/assets/logo/iris-mark.svg +42 -0
  44. package/docs/file-access-across-environments.md +8 -5
  45. package/docs/releases/0.2.0.md +48 -0
  46. package/docs/releases/0.2.1.md +56 -0
  47. package/lib/actions.js +520 -591
  48. package/lib/adapters.js +184 -36
  49. package/lib/api.js +200 -11
  50. package/lib/chromium-browser.js +117 -0
  51. package/lib/client.js +1390 -105
  52. package/lib/command-service.js +534 -0
  53. package/lib/composite-vision-routing.js +42 -0
  54. package/lib/config.js +143 -51
  55. package/lib/core-artifact-store.js +630 -0
  56. package/lib/core-artifacts.js +4 -0
  57. package/lib/core-attention.js +103 -0
  58. package/lib/core-contract.js +105 -0
  59. package/lib/core-doctor.js +41 -0
  60. package/lib/core-lease-recovery.js +170 -0
  61. package/lib/core-maintenance.js +213 -0
  62. package/lib/core-runtime.js +232 -0
  63. package/lib/core-tasks.js +567 -0
  64. package/lib/core-user-projection.js +193 -0
  65. package/lib/doctor.js +92 -33
  66. package/lib/dsh-core-adapter.js +653 -0
  67. package/lib/dsh-host-adapter.js +58 -7
  68. package/lib/dsh-text-model-adapter.js +222 -0
  69. package/lib/dsh-vision-model-adapter.js +75 -0
  70. package/lib/generation-input.js +126 -0
  71. package/lib/guard.js +3 -3
  72. package/lib/headless-vision.js +181 -0
  73. package/lib/host-runtime.js +1 -0
  74. package/lib/http-vision-model-adapter.js +187 -0
  75. package/lib/image-edit-input.js +34 -0
  76. package/lib/index.js +323 -188
  77. package/lib/locate.js +86 -28
  78. package/lib/media-probe.js +2 -1
  79. package/lib/model-call-runtime.js +45 -0
  80. package/lib/model-invoker.js +220 -0
  81. package/lib/model-port-contract.js +341 -0
  82. package/lib/models.js +105 -14
  83. package/lib/ocr-model-routing.js +26 -0
  84. package/lib/ocr.js +110 -64
  85. package/lib/prompt-optimizer-config.js +2 -1
  86. package/lib/prompt-optimizer-core.js +121 -0
  87. package/lib/prompt-optimizer.js +37 -193
  88. package/lib/provider-adapter.js +49 -23
  89. package/lib/provider-adapters.js +165 -26
  90. package/lib/provider-catalog.js +183 -0
  91. package/lib/provider-config-service.js +236 -0
  92. package/lib/provider-contract.js +74 -4
  93. package/lib/provider-health.js +80 -1
  94. package/lib/provider-protocol.js +74 -2
  95. package/lib/provider-task-runner.js +370 -0
  96. package/lib/summarize.js +31 -20
  97. package/lib/summary-input.js +52 -0
  98. package/lib/task-wait.js +40 -0
  99. package/lib/tasks.js +16 -4
  100. package/lib/video-input.js +30 -0
  101. package/lib/vision-core.js +42 -0
  102. package/lib/vision-image-input.js +83 -0
  103. package/lib/vision-input.js +31 -0
  104. package/lib/vision-model-routing.js +180 -0
  105. package/lib/vision.js +31 -9
  106. package/lib/workbench-artifacts.js +52 -0
  107. package/lib/workbench-management.js +151 -0
  108. package/lib/workbench-zip.js +39 -0
  109. package/package.json +8 -8
  110. package/user_guide.md +71 -26
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import {
10
10
  ProviderContractError,
11
+ normalizeProviderArtifacts,
11
12
  normalizeSubmissionResult,
12
13
  providerErrorRecord,
13
14
  resultFromThrown
@@ -69,7 +70,8 @@ function normalizedReason(value, fallback) {
69
70
  export function defineProviderAdapter(input) {
70
71
  if (!isPlainObject(input)) throw new TypeError('Provider Adapter 必须是对象');
71
72
  const allowed = new Set([
72
- 'contractVersion', 'id', 'protocol', 'capabilities', 'operations', 'unsupported'
73
+ 'contractVersion', 'id', 'protocol', 'capabilities', 'operations', 'unsupported',
74
+ 'prepareInput', 'unsupportedPreparation'
73
75
  ]);
74
76
  for (const key of Object.keys(input)) {
75
77
  if (!allowed.has(key)) throw new Error('Provider Adapter 不允许顶层字段:' + key);
@@ -118,13 +120,24 @@ export function defineProviderAdapter(input) {
118
120
  throw new Error('Provider Adapter 必须实现 submit 与 mapError');
119
121
  }
120
122
 
123
+ if (input.prepareInput !== undefined && typeof input.prepareInput !== 'function') {
124
+ throw new TypeError('Provider prepareInput 必须是函数');
125
+ }
126
+ if (input.prepareInput && input.unsupportedPreparation !== undefined) {
127
+ throw new Error('Provider 输入准备不能同时声明 supported 与 unsupported');
128
+ }
129
+ const unsupportedPreparation = input.prepareInput ? undefined
130
+ : normalizedReason(input.unsupportedPreparation, '协议未实现临时输入上传');
131
+
121
132
  return Object.freeze({
122
133
  contractVersion,
123
134
  id,
124
135
  protocol,
125
136
  capabilities: Object.freeze(capabilities),
126
137
  operations: Object.freeze(operations),
127
- unsupported: Object.freeze(unsupported)
138
+ unsupported: Object.freeze(unsupported),
139
+ prepareInput: input.prepareInput,
140
+ unsupportedPreparation
128
141
  });
129
142
  }
130
143
 
@@ -170,10 +183,40 @@ export function providerAdapterSnapshot(adapter) {
170
183
  protocol: adapter?.protocol || 'unknown'
171
184
  }),
172
185
  capabilities: Object.freeze([...(adapter?.capabilities || [])]),
173
- operations: Object.freeze(operations)
186
+ operations: Object.freeze(operations),
187
+ inputPreparation: Object.freeze(typeof adapter?.prepareInput === 'function'
188
+ ? { status: 'supported' }
189
+ : { status: 'unsupported', reason: adapter?.unsupportedPreparation || '协议未实现临时输入上传' })
174
190
  });
175
191
  }
176
192
 
193
+ /** 提交前的可选上传能力;不创建 Task,也不属于六个生命周期操作。 */
194
+ export async function prepareProviderInput(adapter, input = {}) {
195
+ if (typeof adapter?.prepareInput !== 'function') {
196
+ const error = new ProviderContractError(adapter?.unsupportedPreparation || '协议未实现临时输入上传', {
197
+ stage: 'upload', category: 'invalid_request', acceptance: 'not_accepted',
198
+ providerCode: 'IRIS_PROVIDER_INPUT_PREPARATION_UNSUPPORTED'
199
+ });
200
+ error.code = 'IRIS_PROVIDER_INPUT_PREPARATION_UNSUPPORTED';
201
+ throw error;
202
+ }
203
+ try {
204
+ const value = await adapter.prepareInput(input);
205
+ if (typeof value?.url !== 'string' || !value.url.trim()) {
206
+ throw new ProviderContractError('Provider 输入准备必须返回非空 url', {
207
+ stage: 'upload', category: 'protocol', acceptance: 'not_accepted'
208
+ });
209
+ }
210
+ // 临时地址只供随后的提交使用,避免随日志或任务 JSON 序列化。
211
+ return Object.freeze(Object.defineProperty({}, 'url', { value: value.url }));
212
+ } catch (error) {
213
+ const context = { stage: 'upload', acceptance: 'not_accepted' };
214
+ const safe = providerErrorRecord(error instanceof ProviderContractError
215
+ ? error : mappedError(adapter, error, context), context);
216
+ throw new ProviderContractError(safe.safeMessage, safe);
217
+ }
218
+ }
219
+
177
220
  export function normalizeDiscoveryResult(value) {
178
221
  const source = Array.isArray(value) ? value : value?.models;
179
222
  if (!Array.isArray(source)) throw new TypeError('Provider discover 必须返回 models 数组');
@@ -193,23 +236,6 @@ export function normalizeDiscoveryResult(value) {
193
236
  return Object.freeze({ models: Object.freeze(models) });
194
237
  }
195
238
 
196
- function normalizeArtifacts(value) {
197
- if (value === undefined) return Object.freeze([]);
198
- if (!Array.isArray(value)) throw new TypeError('Provider poll artifacts 必须是数组');
199
- return Object.freeze(value.map((item) => {
200
- if (!isPlainObject(item) || item.kind !== 'remote-url') {
201
- throw new TypeError('Provider artifact 当前只支持 remote-url');
202
- }
203
- const url = String(item.url || '').trim();
204
- if (!url) throw new TypeError('Provider remote-url artifact 缺少 url');
205
- return Object.freeze({
206
- kind: 'remote-url',
207
- url,
208
- ...(typeof item.mediaType === 'string' && item.mediaType ? { mediaType: item.mediaType } : {})
209
- });
210
- }));
211
- }
212
-
213
239
  /**
214
240
  * poll 标准结果。兼容旧 {done,ok,urls,text,status,message} 仅用于 0.1.x 渐进迁移;
215
241
  * 新 Adapter 必须直接返回 kind 结果。
@@ -225,7 +251,7 @@ export function normalizeProviderPollResult(value) {
225
251
  if (value.ok) {
226
252
  return Object.freeze({
227
253
  kind: 'succeeded',
228
- artifacts: normalizeArtifacts((value.urls || []).map((url) => ({ kind: 'remote-url', url }))),
254
+ artifacts: normalizeProviderArtifacts((value.urls || []).map((url) => ({ kind: 'remote-url', url }))),
229
255
  ...(typeof value.text === 'string'
230
256
  ? { value: Object.freeze({ kind: 'text', text: value.text }) }
231
257
  : {})
@@ -251,7 +277,7 @@ export function normalizeProviderPollResult(value) {
251
277
  if (value.kind === 'succeeded') {
252
278
  return Object.freeze({
253
279
  kind: 'succeeded',
254
- artifacts: normalizeArtifacts(value.artifacts),
280
+ artifacts: normalizeProviderArtifacts(value.artifacts),
255
281
  ...(value.value !== undefined ? { value: value.value } : {})
256
282
  });
257
283
  }
@@ -356,7 +382,7 @@ export function providerPollTaskView(value) {
356
382
  return { kind: result.kind, done: false, ok: false, urls: [], status: result.progress || 'running' };
357
383
  }
358
384
  if (result.kind === 'succeeded') {
359
- const urls = result.artifacts.map((item) => item.url);
385
+ const urls = result.artifacts.filter((item) => item.kind === 'remote-url').map((item) => item.url);
360
386
  if (result.value?.kind === 'text') urls.push(String(result.value.text || ''));
361
387
  return { kind: result.kind, done: true, ok: true, urls, value: result.value };
362
388
  }
@@ -8,15 +8,18 @@
8
8
  */
9
9
  import * as defaultTransport from './adapters.js';
10
10
  import { ProviderContractError, providerErrorRecord } from './provider-contract.js';
11
+ import { writePrivateFile } from './private-storage.js';
11
12
  import { defineProviderAdapter } from './provider-adapter.js';
12
- import { inferMediaProtocol } from './provider-protocol.js';
13
+ import { selectMediaProtocol, providerApiKey, providerMediaBaseUrl, unsupportedProtocolError } from './provider-protocol.js';
14
+ import { modelRateLimit, reportModelRateLimit, isFreeQuotaExhausted } from './provider-health.js';
15
+ import { prepareVisionImage, visionInputLimits } from './vision-image-input.js';
13
16
 
14
17
  const AMBIGUOUS_4XX = new Set([408, 409, 425, 499]);
15
18
 
16
19
  function providerIdentity(provider) {
17
20
  const id = String(provider?.id || '').trim();
18
- const key = String(provider?.apiKey || '');
19
- const baseUrl = String(provider?.baseUrl || '').trim();
21
+ const key = providerApiKey(provider);
22
+ const baseUrl = providerMediaBaseUrl(provider);
20
23
  if (!id) throw new TypeError('Provider 配置缺少 id');
21
24
  if (!baseUrl) throw new TypeError('Provider 配置缺少 baseUrl');
22
25
  return { id, key, baseUrl };
@@ -26,17 +29,19 @@ function mappedCategory(error, status) {
26
29
  const raw = String(error?.category || '').toLowerCase();
27
30
  if (error?.name === 'AbortError' || error?.code === 'ABORT_ERR') return 'aborted';
28
31
  if (error?.name === 'TimeoutError' || /timeout|timed out|超时/i.test(String(error?.message || ''))) return 'timeout';
32
+ if (isFreeQuotaExhausted(error)) return 'quota';
29
33
  if (status === 401 || status === 403 || raw === 'auth') return 'authentication';
30
34
  if (status === 429 && raw === 'quota') return 'quota';
31
35
  if (status === 429 || raw === 'rate_limit') return 'rate_limit';
32
- if (raw === 'invalid_parameter') return 'invalid_request';
36
+ if (raw === 'invalid_parameter' || raw === 'invalid_request') return 'invalid_request';
37
+ if (raw === 'protocol') return 'protocol';
33
38
  if (raw === 'network' || error instanceof TypeError) return 'network';
34
39
  if (raw === 'server' || status >= 500) return 'provider';
35
40
  if (!status && /未返回|响应|协议|结构|json/i.test(String(error?.message || ''))) return 'protocol';
36
41
  return 'provider';
37
42
  }
38
43
 
39
- /** 当前两个协议共享的错误映射;不保留 cause、rootCause、正文、URL 或凭据。 */
44
+ /** 已支持协议共享的错误映射;不保留 cause、rootCause、正文、URL 或凭据。 */
40
45
  export function mapProviderAdapterError(error, context = {}) {
41
46
  const statusValue = Number(error?.httpStatus ?? error?.status);
42
47
  const httpStatus = Number.isInteger(statusValue) && statusValue >= 100 && statusValue <= 599
@@ -82,6 +87,36 @@ function pollFailureKind(status) {
82
87
  return 'failed';
83
88
  }
84
89
 
90
+ function remoteImageArtifacts(urls) {
91
+ return (urls || []).map((url) => ({ kind: 'remote-url', url, mediaType: 'image/png' }));
92
+ }
93
+
94
+ function openAiImageArtifacts(items) {
95
+ return (items || []).map((item) => {
96
+ if (item && item.b64) {
97
+ return { kind: 'inline-base64', data: item.b64, mediaType: item.mediaType || 'image/png' };
98
+ }
99
+ if (item && item.url) {
100
+ return { kind: 'remote-url', url: item.url, mediaType: item.mediaType || 'image/png' };
101
+ }
102
+ throw new ProviderContractError('同步图片结果缺少 b64 或 URL', {
103
+ stage: 'response', category: 'protocol', acceptance: 'accepted'
104
+ });
105
+ });
106
+ }
107
+
108
+ function writeInlineArtifact(artifact, targetPath) {
109
+ const data = String(artifact?.data || '').trim();
110
+ const body = Buffer.from(data, 'base64');
111
+ if (!data || body.length === 0 || body.toString('base64') !== data) {
112
+ throw new ProviderContractError('inline-base64 图片数据无效', {
113
+ stage: 'download', category: 'protocol', acceptance: 'accepted'
114
+ });
115
+ }
116
+ writePrivateFile(targetPath, body, { flag: 'wx' });
117
+ return { bytes: body.length };
118
+ }
119
+
85
120
  function operationsForDashScope(provider, transport) {
86
121
  const { key, baseUrl } = providerIdentity(provider);
87
122
  return {
@@ -105,7 +140,10 @@ function operationsForDashScope(provider, transport) {
105
140
  const urls = await transport.generateImageMultimodal({
106
141
  ...common, prompt: input.prompt, size: input.size, n: input.n
107
142
  });
108
- return { kind: 'completed', value: { kind: 'urls', items: urls } };
143
+ return {
144
+ kind: 'completed', value: { kind: 'urls', items: urls },
145
+ artifacts: remoteImageArtifacts(urls)
146
+ };
109
147
  }
110
148
  if (request.capability === 'video') {
111
149
  const remoteTaskId = await transport.submitVideo({
@@ -129,7 +167,11 @@ function operationsForDashScope(provider, transport) {
129
167
  const value = await transport.synthesizeTts({
130
168
  ...common, text: input.text, voice: input.voice
131
169
  });
132
- return { kind: 'completed', value };
170
+ // E2:同步合成产物物化为 Core Artifact——URL 走下载,inline base64 直接落盘。
171
+ const artifacts = value.audioUrl
172
+ ? [{ kind: 'remote-url', url: value.audioUrl, mediaType: 'audio/mpeg' }]
173
+ : [{ kind: 'inline-base64', data: value.audioB64, mediaType: 'audio/mpeg' }];
174
+ return { kind: 'completed', value, artifacts };
133
175
  }
134
176
  throw unsupportedCapability(request.capability, 'dashscope');
135
177
  },
@@ -150,7 +192,13 @@ function operationsForDashScope(provider, transport) {
150
192
  })
151
193
  };
152
194
  }
153
- return { kind: 'succeeded', value: { kind: 'text', text: String(result.text || '') } };
195
+ // E3:转写正文物化为 text/plain Artifact(inline-base64,不再依赖签名 URL 生命周期)。
196
+ const text = String(result.text || '');
197
+ return {
198
+ kind: 'succeeded',
199
+ value: { kind: 'text', text },
200
+ artifacts: [{ kind: 'inline-base64', data: Buffer.from(text, 'utf8').toString('base64'), mediaType: 'text/plain' }]
201
+ };
154
202
  }
155
203
  if (request.capability === 'image' || request.capability === 'video') {
156
204
  const result = await transport.pollTask(common);
@@ -173,17 +221,20 @@ function operationsForDashScope(provider, transport) {
173
221
 
174
222
  async download(request = {}) {
175
223
  const artifact = request.artifact || {};
176
- if (artifact.kind !== 'remote-url' || !artifact.url) {
177
- throw new ProviderContractError('download 需要 remote-url artifact', {
178
- stage: 'download', category: 'protocol', acceptance: 'accepted'
179
- });
180
- }
181
224
  const targetPath = String(request.targetPath || '').trim();
182
225
  if (!targetPath) {
183
226
  throw new ProviderContractError('download 需要 targetPath', {
184
227
  stage: 'download', category: 'local_io', acceptance: 'accepted'
185
228
  });
186
229
  }
230
+ if (artifact.kind === 'inline-base64') {
231
+ return writeInlineArtifact(artifact, targetPath);
232
+ }
233
+ if (artifact.kind !== 'remote-url' || !artifact.url) {
234
+ throw new ProviderContractError('download 需要 remote-url artifact', {
235
+ stage: 'download', category: 'protocol', acceptance: 'accepted'
236
+ });
237
+ }
187
238
  return {
188
239
  bytes: await transport.downloadTo(artifact.url, targetPath, {
189
240
  signal: request.signal,
@@ -197,18 +248,22 @@ function operationsForDashScope(provider, transport) {
197
248
  }
198
249
 
199
250
  export function createDashScopeProviderAdapter(provider, { transport = defaultTransport } = {}) {
251
+ const { key, baseUrl } = providerIdentity(provider);
200
252
  return defineProviderAdapter({
201
253
  id: providerIdentity(provider).id,
202
254
  protocol: 'dashscope',
203
255
  capabilities: ['image', 'video', 'tts', 'transcribe'],
204
256
  operations: operationsForDashScope(provider, transport),
257
+ async prepareInput({ model, filePath, signal, timeoutMs } = {}) {
258
+ return { url: await transport.uploadTempFile({ key, baseUrl, model, filePath, signal, timeoutMs }) };
259
+ },
205
260
  unsupported: {
206
261
  cancel: '当前 DashScope 媒体协议未提供经过验证的远端取消实现'
207
262
  }
208
263
  });
209
264
  }
210
265
 
211
- function operationsForOpenAiImages(provider, transport) {
266
+ function operationsForOpenAiImages(provider, transport, protocol = 'openai-images') {
212
267
  const { key, baseUrl } = providerIdentity(provider);
213
268
  return {
214
269
  async discover({ signal, timeoutMs } = {}) {
@@ -217,35 +272,52 @@ function operationsForOpenAiImages(provider, transport) {
217
272
 
218
273
  async submit(request = {}) {
219
274
  if (request.capability !== 'image') {
220
- throw unsupportedCapability(request.capability, 'openai-images');
275
+ throw unsupportedCapability(request.capability, protocol);
221
276
  }
222
277
  const input = request.input || {};
223
- const items = await transport.openAiGenerateImage({
278
+ let image;
279
+ if (input.image) {
280
+ if (protocol !== 'openai-chat-images') throw new ProviderContractError('该图片协议不支持聊天改图', {
281
+ stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
282
+ });
283
+ image = await prepareVisionImage(input.image, visionInputLimits(provider, request.model), { signal: request.signal, orient: true });
284
+ }
285
+ const method = {
286
+ 'openai-images': 'openAiGenerateImage',
287
+ 'openai-chat-images': 'openAiChatGenerateImage',
288
+ 'openai-responses-images': 'openAiResponsesGenerateImage'
289
+ }[protocol];
290
+ const items = await transport[method]({
224
291
  key,
225
292
  baseUrl,
226
293
  model: request.model,
227
294
  prompt: input.prompt,
295
+ ...(image ? { image } : {}),
228
296
  size: input.size,
229
297
  n: input.n,
230
298
  signal: request.signal,
231
299
  ...(request.timeoutMs ? { timeoutMs: request.timeoutMs } : {})
232
300
  });
233
- return { kind: 'completed', value: { kind: 'openai', items } };
301
+ return {
302
+ kind: 'completed', value: { kind: 'openai', items },
303
+ artifacts: openAiImageArtifacts(items)
304
+ };
234
305
  },
235
306
 
236
307
  async download(request = {}) {
237
308
  const artifact = request.artifact || {};
238
- if (artifact.kind !== 'remote-url' || !artifact.url) {
239
- throw new ProviderContractError('download 需要 remote-url artifact', {
240
- stage: 'download', category: 'protocol', acceptance: 'accepted'
241
- });
242
- }
243
309
  const targetPath = String(request.targetPath || '').trim();
244
310
  if (!targetPath) {
245
311
  throw new ProviderContractError('download 需要 targetPath', {
246
312
  stage: 'download', category: 'local_io', acceptance: 'accepted'
247
313
  });
248
314
  }
315
+ if (artifact.kind === 'inline-base64') return writeInlineArtifact(artifact, targetPath);
316
+ if (artifact.kind !== 'remote-url' || !artifact.url) {
317
+ throw new ProviderContractError('download 需要可物化的图片 artifact', {
318
+ stage: 'download', category: 'protocol', acceptance: 'accepted'
319
+ });
320
+ }
249
321
  return {
250
322
  bytes: await transport.downloadTo(artifact.url, targetPath, {
251
323
  signal: request.signal,
@@ -264,6 +336,7 @@ export function createOpenAiImagesProviderAdapter(provider, { transport = defaul
264
336
  protocol: 'openai-images',
265
337
  capabilities: ['image'],
266
338
  operations: operationsForOpenAiImages(provider, transport),
339
+ unsupportedPreparation: 'OpenAI Images 兼容路径未实现临时输入上传',
267
340
  unsupported: {
268
341
  poll: 'OpenAI Images 兼容路径在当前实现中同步返回结果',
269
342
  cancel: '同步图片响应没有可验证的远端取消任务'
@@ -271,9 +344,75 @@ export function createOpenAiImagesProviderAdapter(provider, { transport = defaul
271
344
  });
272
345
  }
273
346
 
347
+ export function createOpenAiChatImagesProviderAdapter(provider, { transport = defaultTransport } = {}) {
348
+ return defineProviderAdapter({
349
+ id: providerIdentity(provider).id,
350
+ protocol: 'openai-chat-images',
351
+ capabilities: ['image'],
352
+ operations: operationsForOpenAiImages(provider, transport, 'openai-chat-images'),
353
+ unsupported: {
354
+ poll: 'Chat Completions 生图为同步协议,不提供远端任务轮询',
355
+ cancel: 'Chat Completions 生图不提供远端取消'
356
+ },
357
+ unsupportedPreparation: 'Chat Completions 生图未实现临时输入上传'
358
+ });
359
+ }
360
+
361
+ export function createOpenAiResponsesImagesProviderAdapter(provider, { transport = defaultTransport } = {}) {
362
+ return defineProviderAdapter({
363
+ id: providerIdentity(provider).id,
364
+ protocol: 'openai-responses-images',
365
+ capabilities: ['image'],
366
+ operations: operationsForOpenAiImages(provider, transport, 'openai-responses-images'),
367
+ unsupported: {
368
+ poll: 'Responses 生图当前只接同步结果,不提供后台任务轮询',
369
+ cancel: '同步 Responses 生图不提供远端取消'
370
+ },
371
+ unsupportedPreparation: 'Responses 生图未实现临时输入上传'
372
+ });
373
+ }
374
+
375
+ const PROTOCOL_FACTORIES = new Map([
376
+ ['dashscope', createDashScopeProviderAdapter],
377
+ ['openai-images', createOpenAiImagesProviderAdapter],
378
+ ['openai-chat-images', createOpenAiChatImagesProviderAdapter],
379
+ ['openai-responses-images', createOpenAiResponsesImagesProviderAdapter]
380
+ ]);
381
+
274
382
  export function createConfiguredProviderAdapter(provider, options = {}) {
275
- const protocol = options.protocol || provider?.mediaProtocol || inferMediaProtocol(provider?.baseUrl);
276
- if (protocol === 'dashscope') return createDashScopeProviderAdapter(provider, options);
277
- if (protocol === 'openai-images') return createOpenAiImagesProviderAdapter(provider, options);
278
- throw new TypeError('iris: 不支持的 Provider 协议:' + String(protocol));
383
+ const selection = options.protocol
384
+ ? selectMediaProtocol({ ...provider, mediaProtocol: options.protocol, protocolInferred: false })
385
+ : selectMediaProtocol(provider);
386
+ const factory = PROTOCOL_FACTORIES.get(selection.mediaProtocol);
387
+ if (!factory) throw unsupportedProtocolError(selection.mediaProtocol);
388
+ const adapter = factory(provider, options);
389
+ const operations = { ...adapter.operations };
390
+ for (const name of ['submit', 'poll']) {
391
+ if (!operations[name]) continue;
392
+ const run = operations[name];
393
+ operations[name] = async request => {
394
+ const modelId = request.model || options.modelId;
395
+ const report = error => reportModelRateLimit(provider, modelId, {
396
+ httpStatus: error.httpStatus ?? error.status, category: error.category,
397
+ code: error.code, providerCode: error.providerCode, message: error.safeMessage || error.message,
398
+ retryAfterMs: error.retryAfterMs,
399
+ source: options.allowRateLimited ? 'probe' : 'task'
400
+ }, options.onRateLimit, request.capability);
401
+ if (name === 'submit' && !options.allowRateLimited && modelRateLimit(provider, modelId)) {
402
+ const blocked = modelRateLimit(provider, modelId);
403
+ throw new ProviderContractError(blocked.until ? '该模型正在限流冷却,请稍后使用' : '该模型额度或预算耗尽,请手动实测成功后再使用', {
404
+ stage: 'validate', category: 'rate_limit', acceptance: 'not_accepted'
405
+ });
406
+ }
407
+ try {
408
+ const result = await run(request);
409
+ if (result.error) report(result.error);
410
+ return result;
411
+ } catch (error) {
412
+ report(error);
413
+ throw error;
414
+ }
415
+ };
416
+ }
417
+ return Object.freeze({ ...adapter, operations: Object.freeze(operations) });
279
418
  }
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { CAPABILITIES } from './capability.js';
7
+ import { modelPool, orderedModels, providerModels, parseModelRef, resolveModelRef, resolvePoolModel, selectionReasonForModel } from './models.js';
8
+ import { dashscopeApiBase, selectMediaProtocol, providerForImageModel, providerMediaBaseUrl, isConfiguredProvider } from './provider-protocol.js';
9
+ import { redactProviderMessage } from './provider-contract.js';
10
+ import { modelRateLimit } from './provider-health.js';
11
+ import { normalizeVisionInputLimits } from './vision-image-input.js';
12
+
13
+ export class ProviderCatalogError extends Error {
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.name = 'ProviderCatalogError';
17
+ this.code = code;
18
+ }
19
+ }
20
+
21
+ function fail(code, message) {
22
+ throw new ProviderCatalogError(code, message);
23
+ }
24
+
25
+ /** Headless 边界只读取显式配置文件;不推断 DSH_HOME、HOME 或 cwd。 */
26
+ export function loadProviderCatalog(configPath) {
27
+ const supplied = String(configPath || '').trim();
28
+ if (!supplied || !path.isAbsolute(supplied)) {
29
+ fail('IRIS_PROVIDER_CONFIG_INVALID', 'provider-config 必须是显式绝对路径');
30
+ }
31
+ let source;
32
+ try {
33
+ const stat = fs.lstatSync(supplied);
34
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('not a regular file');
35
+ if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) {
36
+ fail('IRIS_PROVIDER_CONFIG_PERMISSIONS', 'provider-config 权限过宽;请限制为仅当前用户可读写');
37
+ }
38
+ source = fs.readFileSync(supplied, 'utf8');
39
+ } catch (error) {
40
+ if (error instanceof ProviderCatalogError) throw error;
41
+ fail('IRIS_PROVIDER_CONFIG_UNREADABLE', 'provider-config 不存在、不是普通文件或无法读取');
42
+ }
43
+ let parsed;
44
+ try { parsed = JSON.parse(source); }
45
+ catch (_) { fail('IRIS_PROVIDER_CONFIG_INVALID', 'provider-config 不是有效 JSON'); }
46
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)
47
+ || !Array.isArray(parsed.providers)
48
+ || (parsed.assignments !== undefined && (!parsed.assignments || typeof parsed.assignments !== 'object'
49
+ || Array.isArray(parsed.assignments)))) {
50
+ fail('IRIS_PROVIDER_CONFIG_INVALID', 'provider-config 结构无效');
51
+ }
52
+ return parsed;
53
+ }
54
+
55
+ const CANDIDATE_CAP_LABEL = Object.freeze({
56
+ [CAPABILITIES.IMAGE]: '图片', [CAPABILITIES.VIDEO]: '视频',
57
+ [CAPABILITIES.TTS]: '语音', [CAPABILITIES.TRANSCRIBE]: '转写', [CAPABILITIES.VISION]: '视觉'
58
+ });
59
+
60
+ /** 与稳定配置层相同的“手工顺序优先、池序补齐”候选规则。 */
61
+ function candidatesFromCatalog(catalog, requestedModelRef, capability) {
62
+ const label = CANDIDATE_CAP_LABEL[capability];
63
+ if (!label) fail('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', '当前 Headless 候选链只开放图片、视频、语音与转写能力');
64
+ const providers = catalog.providers.filter(isConfiguredProvider);
65
+ const pool = modelPool(providers).filter((item) => item.capabilities.includes(capability));
66
+ const requested = String(requestedModelRef || '').trim();
67
+ if (requested) {
68
+ const parsed = parseModelRef(requested);
69
+ if (!parsed && requested.includes('::')) fail('IRIS_PROVIDER_MODEL_INVALID', 'model_ref 格式无效');
70
+ const exact = resolvePoolModel(pool, requested, capability);
71
+ if (!exact) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `指定模型不存在、未启用或没有${label}能力`);
72
+ const provider = providers.find((item) => item.id === exact.providerId);
73
+ if (exact.rateLimited) fail('IRIS_PROVIDER_MODEL_RATE_LIMITED', modelRateLimit(provider, exact.id)?.until
74
+ ? '指定模型正在限流冷却,请稍后使用或手动实测'
75
+ : '指定模型额度或预算耗尽,请手动实测成功后再使用');
76
+ return Object.freeze([Object.freeze({
77
+ provider, model: exact.id, modelRef: resolveModelRef(provider, capability, requested),
78
+ selectionReason: 'explicit'
79
+ })]);
80
+ }
81
+ const assignment = catalog.assignments?.[capability];
82
+ const ordered = orderedModels(pool, capability, assignment);
83
+ if (!ordered.length) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `provider-config 中没有可用${label}模型`);
84
+ return Object.freeze(ordered.map((item) => Object.freeze({
85
+ provider: providers.find((provider) => provider.id === item.providerId),
86
+ model: item.id,
87
+ modelRef: item.ref,
88
+ selectionReason: selectionReasonForModel(pool, capability, assignment, item)
89
+ })));
90
+ }
91
+
92
+ export function imageCandidatesFromCatalog(catalog, requestedModelRef = '') {
93
+ return Object.freeze(candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.IMAGE).map(route =>
94
+ Object.freeze({ ...route, provider: providerForImageModel(route.provider, route.model) })));
95
+ }
96
+
97
+ export function videoCandidatesFromCatalog(catalog, requestedModelRef = '') {
98
+ return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.VIDEO);
99
+ }
100
+
101
+ export function ttsCandidatesFromCatalog(catalog, requestedModelRef = '') {
102
+ return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TTS);
103
+ }
104
+
105
+ export function transcribeCandidatesFromCatalog(catalog, requestedModelRef = '') {
106
+ return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TRANSCRIBE);
107
+ }
108
+
109
+ export function visionCandidatesFromCatalog(catalog, requestedModelRef = '') {
110
+ return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.VISION);
111
+ }
112
+
113
+ /** 只读配置投影;不构造 Adapter、不发现模型、不暴露端点或凭据。 */
114
+ export function providerCatalogSnapshot(catalog) {
115
+ const safeLabel = (value) => value
116
+ ? redactProviderMessage(String(value).replace(/https?:\/\/[^\s<>"']+/gi, '[URL]')) : '';
117
+ return {
118
+ schemaVersion: 1,
119
+ providers: catalog.providers.filter((provider) => provider && typeof provider === 'object').map((provider) => {
120
+ const models = providerModels(provider);
121
+ const selection = selectMediaProtocol(provider);
122
+ return {
123
+ id: safeLabel(provider.id), name: safeLabel(provider.name || provider.id),
124
+ enabled: provider.enabled !== false, configured: isConfiguredProvider(provider),
125
+ auth: provider.auth === 'none' ? 'none' : 'bearer',
126
+ mediaProtocol: safeLabel(selection.mediaProtocol), protocolInferred: selection.protocolInferred,
127
+ ...(provider.visionInput ? { visionInput: normalizeVisionInputLimits(provider.visionInput) } : {}),
128
+ capabilities: Object.values(CAPABILITIES).filter((capability) => models.some((model) => model.capabilities.includes(capability))),
129
+ modelCount: models.length
130
+ };
131
+ })
132
+ };
133
+ }
134
+
135
+ export function catalogCapabilitySnapshot(catalog) {
136
+ const pool = modelPool(catalog.providers.filter(isConfiguredProvider));
137
+ return {
138
+ schemaVersion: 1,
139
+ source: 'configuration',
140
+ capabilities: Object.values(CAPABILITIES).map((capability) => {
141
+ const candidates = orderedModels(pool, capability, catalog.assignments?.[capability]).map((model) => model.ref);
142
+ return { capability, candidates, ...(candidates.length ? {} : { gap: 'no_configured_model' }) };
143
+ })
144
+ };
145
+ }
146
+
147
+ /** 非敏感摘要把 Task 绑定到提交时实际使用的媒体端点与协议。 */
148
+ export function providerTaskBinding(provider) {
149
+ const baseUrl = providerMediaBaseUrl(provider);
150
+ const protocol = selectMediaProtocol(provider).mediaProtocol;
151
+ let endpoint;
152
+ try {
153
+ const url = new URL(baseUrl);
154
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('unsafe endpoint');
155
+ if (!['dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images'].includes(protocol)) throw new Error('unknown protocol');
156
+ endpoint = protocol === 'dashscope' ? dashscopeApiBase(baseUrl) : url.href.replace(/\/+$/, '');
157
+ } catch (_) { fail('IRIS_PROVIDER_CONFIG_INVALID', 'Provider 媒体端点或协议无效'); }
158
+ return 'sha256:' + crypto.createHash('sha256').update(JSON.stringify([1, protocol, endpoint])).digest('hex');
159
+ }
160
+
161
+ /** 恢复只使用 Task 已保存的 Provider/模型;忽略当前 assignment 与 failover 顺序。 */
162
+ export function providerForTaskFromCatalog(catalog, task) {
163
+ const capability = CAPABILITIES[
164
+ task?.capability === 'video' ? 'VIDEO'
165
+ : task?.capability === 'image' ? 'IMAGE'
166
+ : task?.capability === 'tts' ? 'TTS'
167
+ : task?.capability === 'transcribe' ? 'TRANSCRIBE' : ''
168
+ ];
169
+ if (!capability) fail('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', '当前 Headless 恢复解析只开放图片、视频、语音与转写 Task');
170
+ const label = { video: '视频', tts: '语音', transcribe: '转写' }[task.capability] || '图片';
171
+ const parsed = parseModelRef(task.modelRef);
172
+ if (!parsed || parsed.providerId !== task.providerId) fail('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Task 的 Provider 与模型身份不一致');
173
+ const matches = catalog.providers.filter((item) => item && item.id === task.providerId);
174
+ if (matches.length > 1) fail('IRIS_PROVIDER_CONFIG_AMBIGUOUS', 'provider-config 存在重复 Provider ID');
175
+ const configured = matches[0];
176
+ if (!configured || configured.enabled === false || (configured.auth !== 'none' && (typeof configured.apiKey !== 'string' || !configured.apiKey.trim()))) fail('IRIS_PROVIDER_TASK_PROVIDER_UNAVAILABLE', 'Task 原 Provider 已移除、停用或缺少凭据');
177
+ const provider = task.capability === 'image' ? providerForImageModel(configured, parsed.modelId) : configured;
178
+ if (!task.providerBinding) fail('IRIS_PROVIDER_TASK_BINDING_MISSING', '旧 Task 缺少提交端点绑定;仅支持只读查看,不猜测恢复配置');
179
+ if (providerTaskBinding(provider) !== task.providerBinding) fail('IRIS_PROVIDER_TASK_BINDING_MISMATCH', 'Task 提交端点或协议已改变;拒绝向新端点观察原任务');
180
+ const model = modelPool([provider]).find((item) => item.providerId === parsed.providerId && item.id === parsed.modelId && item.capabilities.includes(capability));
181
+ if (!model) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `Task 原模型已移除或不再具备${label}能力`);
182
+ return provider;
183
+ }