@mokuyoaxis/dsh-iris 0.1.4 → 0.2.0

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 (64) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/README.en.md +72 -15
  3. package/README.md +72 -15
  4. package/bin/dsh-iris.js +392 -13
  5. package/docs/AGENT_QUICKSTART.md +35 -0
  6. package/docs/ARCHITECTURE.md +80 -64
  7. package/docs/ARTIFACT_MANIFEST.md +60 -0
  8. package/docs/CORE_RUNTIME_CONTRACT.md +83 -0
  9. package/docs/DSH_CORE_MIGRATION.md +106 -0
  10. package/docs/DSH_RC2_ADAPTATION.md +36 -0
  11. package/docs/HEADLESS_CLI.md +217 -0
  12. package/docs/HOST_ADAPTER_CONTRACT.md +31 -12
  13. package/docs/HOST_DOCTOR.md +2 -0
  14. package/docs/MODEL_PORT_CONTRACT.md +300 -0
  15. package/docs/PROVIDER_ADAPTER_CONTRACT.md +51 -10
  16. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  17. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +1 -1
  18. package/docs/ROADMAP.md +42 -86
  19. package/docs/SECURITY.md +4 -3
  20. package/docs/assets/logo/README.md +55 -0
  21. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  22. package/docs/assets/logo/iris-bubble.svg +14 -0
  23. package/docs/assets/logo/iris-interim-flower.png +0 -0
  24. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  25. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  26. package/docs/assets/logo/iris-logo.svg +78 -0
  27. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  28. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  29. package/docs/assets/logo/iris-mark.svg +42 -0
  30. package/docs/releases/0.2.0.md +48 -0
  31. package/lib/actions.js +374 -362
  32. package/lib/adapters.js +52 -15
  33. package/lib/api.js +152 -5
  34. package/lib/client.js +763 -48
  35. package/lib/command-service.js +420 -0
  36. package/lib/config.js +56 -43
  37. package/lib/core-artifact-store.js +598 -0
  38. package/lib/core-artifacts.js +4 -0
  39. package/lib/core-attention.js +103 -0
  40. package/lib/core-contract.js +105 -0
  41. package/lib/core-doctor.js +41 -0
  42. package/lib/core-lease-recovery.js +170 -0
  43. package/lib/core-runtime.js +232 -0
  44. package/lib/core-tasks.js +560 -0
  45. package/lib/core-user-projection.js +192 -0
  46. package/lib/doctor.js +92 -33
  47. package/lib/dsh-core-adapter.js +599 -0
  48. package/lib/dsh-host-adapter.js +58 -7
  49. package/lib/generation-input.js +95 -0
  50. package/lib/guard.js +3 -3
  51. package/lib/index.js +159 -54
  52. package/lib/model-invoker.js +218 -0
  53. package/lib/model-port-contract.js +333 -0
  54. package/lib/models.js +97 -11
  55. package/lib/provider-adapter.js +49 -23
  56. package/lib/provider-adapters.js +84 -21
  57. package/lib/provider-catalog.js +171 -0
  58. package/lib/provider-contract.js +72 -3
  59. package/lib/provider-protocol.js +51 -2
  60. package/lib/provider-task-runner.js +328 -0
  61. package/lib/tasks.js +16 -4
  62. package/lib/vision.js +28 -7
  63. package/package.json +8 -8
  64. package/user_guide.md +27 -12
package/lib/models.js CHANGED
@@ -9,9 +9,10 @@
9
9
  * ① provider 显式 `models` 数组(未来配置,可带 capabilities 覆盖)
10
10
  * ② 旧模型字段迁移:imageModel→image-gen、videoModel→video-gen、ttsModel→tts、
11
11
  * transcribeModel→transcribe、visionModel→vision
12
- * ③ 裸 DashScope 账号:已知模型清单(wan 系列 / qwen-vl / qwen-tts)作为池
12
+ * 旧裸账号的已知目录仅由配置写入边界物化,不参与运行时回退。
13
13
  */
14
14
  import { CAPABILITIES } from './capability.js';
15
+ import { isDashScopeBaseUrl, selectMediaProtocol } from './provider-protocol.js';
15
16
 
16
17
  /** 能力 → provider 上的模型字段(保持向后兼容的返回形状) */
17
18
  export const CAP_FIELD = {
@@ -62,8 +63,8 @@ const MODEL_CAP_RULES = [
62
63
  [/^gemini/i, [CAPABILITIES.VISION]] // Gemini 默认按视觉能力纳入(可被显式配置覆盖)
63
64
  ];
64
65
 
65
- /** 裸 DashScope 已知模型清单(无显式模型字段时作为池) */
66
- const DASHSCOPE_KNOWN = [
66
+ /** 仅供配置写入时接回旧裸账号,不参与运行时选型。 */
67
+ const KNOWN_MODELS_BY_PROTOCOL = { dashscope: [
67
68
  { id: 'wan2.2-t2i-flash', caps: [CAPABILITIES.IMAGE] },
68
69
  { id: 'wan2.2-t2v-flash', caps: [CAPABILITIES.VIDEO] },
69
70
  { id: 'wan2.2-s2v-flash', caps: [CAPABILITIES.VIDEO] },
@@ -72,7 +73,17 @@ const DASHSCOPE_KNOWN = [
72
73
  { id: 'qwen3-vl-235b-a22b-thinking', caps: [CAPABILITIES.VISION] },
73
74
  { id: 'qwen-tts-latest', caps: [CAPABILITIES.TTS] },
74
75
  { id: 'qwen-audio-3.0-asr-flash-filetrans', caps: [CAPABILITIES.TRANSCRIBE] }
75
- ];
76
+ ] };
77
+
78
+ export function migrateProviderModels(provider) {
79
+ if (Array.isArray(provider.models) || Object.values(CAP_FIELD).some((field) => provider[field])) return false;
80
+ const protocol = selectMediaProtocol(provider).mediaProtocol;
81
+ if (!isDashScopeBaseUrl(provider.baseUrl) || !Object.hasOwn(KNOWN_MODELS_BY_PROTOCOL, protocol)) return false;
82
+ provider.models = KNOWN_MODELS_BY_PROTOCOL[protocol].map((m) => ({
83
+ id: m.id, capabilities: [...m.caps], source: 'migration'
84
+ }));
85
+ return true;
86
+ }
76
87
 
77
88
  /** 按模型名规则推断能力 */
78
89
  export function capabilitiesOfModel(modelName) {
@@ -83,6 +94,24 @@ export function capabilitiesOfModel(modelName) {
83
94
  return [];
84
95
  }
85
96
 
97
+ /** 官方模型目录能力代码 → Iris 能力。未识别代码保守忽略。 */
98
+ const DISCOVERY_CAPABILITIES = Object.freeze({
99
+ IG: CAPABILITIES.IMAGE, IMAGE: CAPABILITIES.IMAGE, 'IMAGE-GEN': CAPABILITIES.IMAGE,
100
+ VG: CAPABILITIES.VIDEO, VIDEO: CAPABILITIES.VIDEO, 'VIDEO-GEN': CAPABILITIES.VIDEO,
101
+ TTS: CAPABILITIES.TTS, ASR: CAPABILITIES.TRANSCRIBE,
102
+ TRANSCRIBE: CAPABILITIES.TRANSCRIBE, VU: CAPABILITIES.VISION, VISION: CAPABILITIES.VISION
103
+ });
104
+
105
+ /** 发现元数据优先,名称规则兜底;用户保存后的显式 capabilities 仍是最终覆盖。 */
106
+ export function capabilitiesOfDiscoveredModel(model) {
107
+ const id = String(typeof model === 'string' ? model : model?.id || '').trim();
108
+ const declared = Array.isArray(model?.capabilities)
109
+ ? [...new Set(model.capabilities.map((value) => DISCOVERY_CAPABILITIES[String(value).toUpperCase()]).filter(Boolean))]
110
+ : [];
111
+ if (declared.length) return declared;
112
+ return capabilitiesOfModel(id);
113
+ }
114
+
86
115
  /** 一个 provider 的模型池条目列表:[{ id, providerId, capabilities:[], verified?, source? }] */
87
116
  export function providerModels(p) {
88
117
  if (!p) return [];
@@ -93,10 +122,13 @@ export function providerModels(p) {
93
122
  out.push({ id: name, providerId: p.id, ref: modelRef(p.id, name), capabilities: [...caps], ...(extra || {}) });
94
123
  };
95
124
  // ① 显式 models 数组(带可选 capabilities 覆盖 + verified/source 透传)
96
- if (Array.isArray(p.models) && p.models.length) {
125
+ // 该字段一旦存在(含空数组)即为权威声明:用户可明确标记「此供应商没有可用模型」,
126
+ // 不得再落入 ② 旧字段迁移或 ③ 裸账号已知池。空数组曾因 `.length` 判定被穿透,
127
+ // 导致用户声明无模型却仍被注入 7 个厂商默认模型(见审计 B-5)。
128
+ if (Array.isArray(p.models)) {
97
129
  for (const m of p.models) {
98
130
  const id = typeof m === 'string' ? m : m && m.id;
99
- // 显式空数组也是权威覆盖:用户可明确标记“此模型无能力”。
131
+ // 元素级空能力数组同样是权威覆盖:用户可明确标记“此模型无能力”。
100
132
  const caps = Array.isArray(m && m.capabilities) ? m.capabilities : capabilitiesOfModel(id);
101
133
  const extra = typeof m === 'object' && m ? { verified: m.verified, source: m.source } : {};
102
134
  push(id, caps, extra);
@@ -107,14 +139,68 @@ export function providerModels(p) {
107
139
  for (const [cap, field] of Object.entries(CAP_FIELD)) {
108
140
  if (p[field]) push(p[field], [cap]);
109
141
  }
110
- if (out.length) return out;
111
- // ③ 裸 DashScope 账号 → 已知模型池
112
- if (/dashscope/i.test(String(p.baseUrl || ''))) {
113
- for (const m of DASHSCOPE_KNOWN) push(m.id, m.caps);
114
- }
115
142
  return out;
116
143
  }
117
144
 
145
+ /** 只解析用户配置中的模型;裸 ID 与复合引用共享能力校验。 */
146
+ export function resolveModelRef(provider, capability, requested = '') {
147
+ const pool = providerModels(provider).filter((m) => m.capabilities.includes(capability));
148
+ const raw = String(requested || '').trim();
149
+ const selected = raw ? resolvePoolModel(pool, raw, capability)
150
+ : pool.find((m) => m.id === provider?.[CAP_FIELD[capability]]) || pool[0];
151
+ return selected?.ref || null;
152
+ }
153
+
154
+ /** 共享旧分配对象、裸名称和复合引用的解析,池顺序决定同名匹配。 */
155
+ export function resolvePoolModel(pool, raw, capability) {
156
+ let providerId = '';
157
+ let modelId = '';
158
+ if (raw && typeof raw === 'object') {
159
+ providerId = String(raw.providerId || '');
160
+ modelId = String(raw.modelId || raw.id || '');
161
+ } else if (typeof raw === 'string') {
162
+ const parsed = parseModelRef(raw);
163
+ if (parsed) ({ providerId, modelId } = parsed);
164
+ else if (!raw.includes(MODEL_REF_SEP)) modelId = raw;
165
+ }
166
+ return pool.find((m) => m.id === modelId && (!providerId || m.providerId === providerId)
167
+ && m.capabilities.includes(capability)) || null;
168
+ }
169
+
170
+ /** DSH 与 CLI 共用的候选顺序:分配优先、按池顺序补齐并去重。 */
171
+ export function orderedModels(pool, capability, assignments) {
172
+ const ordered = [];
173
+ const seen = new Set();
174
+ const add = (model) => {
175
+ if (model && model.capabilities.includes(capability) && !seen.has(model.ref)) {
176
+ seen.add(model.ref);
177
+ ordered.push(model);
178
+ }
179
+ };
180
+ for (const raw of (Array.isArray(assignments) ? assignments : (assignments ? [assignments] : []))) {
181
+ add(resolvePoolModel(pool, raw, capability));
182
+ }
183
+ for (const model of pool) add(model);
184
+ return ordered;
185
+ }
186
+
187
+ /**
188
+ * 返回候选在当前候选链构造时的配置来源。调用方显式 model_ref 由入口直接标为
189
+ * explicit;本函数只区分 assignment 与池序补齐,结果必须随 Attempt 写前落盘,
190
+ * 不能在事后用可变配置重算。
191
+ */
192
+ export function selectionReasonForModel(pool, capability, assignments, modelOrRef) {
193
+ const selected = resolvePoolModel(pool,
194
+ modelOrRef && typeof modelOrRef === 'object' ? modelOrRef.ref : modelOrRef,
195
+ capability);
196
+ if (!selected) return null;
197
+ for (const raw of (Array.isArray(assignments) ? assignments : (assignments ? [assignments] : []))) {
198
+ const assigned = resolvePoolModel(pool, raw, capability);
199
+ if (assigned?.ref === selected.ref) return 'assignment';
200
+ }
201
+ return 'pool';
202
+ }
203
+
118
204
  /** 全局模型池:合并所有 provider 的模型 */
119
205
  export function modelPool(providers) {
120
206
  const pool = [];
@@ -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,16 @@
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';
13
14
 
14
15
  const AMBIGUOUS_4XX = new Set([408, 409, 425, 499]);
15
16
 
16
17
  function providerIdentity(provider) {
17
18
  const id = String(provider?.id || '').trim();
18
- const key = String(provider?.apiKey || '');
19
- const baseUrl = String(provider?.baseUrl || '').trim();
19
+ const key = providerApiKey(provider);
20
+ const baseUrl = providerMediaBaseUrl(provider);
20
21
  if (!id) throw new TypeError('Provider 配置缺少 id');
21
22
  if (!baseUrl) throw new TypeError('Provider 配置缺少 baseUrl');
22
23
  return { id, key, baseUrl };
@@ -82,6 +83,36 @@ function pollFailureKind(status) {
82
83
  return 'failed';
83
84
  }
84
85
 
86
+ function remoteImageArtifacts(urls) {
87
+ return (urls || []).map((url) => ({ kind: 'remote-url', url, mediaType: 'image/png' }));
88
+ }
89
+
90
+ function openAiImageArtifacts(items) {
91
+ return (items || []).map((item) => {
92
+ if (item && item.b64) {
93
+ return { kind: 'inline-base64', data: item.b64, mediaType: 'image/png' };
94
+ }
95
+ if (item && item.url) {
96
+ return { kind: 'remote-url', url: item.url, mediaType: 'image/png' };
97
+ }
98
+ throw new ProviderContractError('同步图片结果缺少 b64 或 URL', {
99
+ stage: 'response', category: 'protocol', acceptance: 'accepted'
100
+ });
101
+ });
102
+ }
103
+
104
+ function writeInlineArtifact(artifact, targetPath) {
105
+ const data = String(artifact?.data || '').trim();
106
+ const body = Buffer.from(data, 'base64');
107
+ if (!data || body.length === 0 || body.toString('base64') !== data) {
108
+ throw new ProviderContractError('inline-base64 图片数据无效', {
109
+ stage: 'download', category: 'protocol', acceptance: 'accepted'
110
+ });
111
+ }
112
+ writePrivateFile(targetPath, body, { flag: 'wx' });
113
+ return { bytes: body.length };
114
+ }
115
+
85
116
  function operationsForDashScope(provider, transport) {
86
117
  const { key, baseUrl } = providerIdentity(provider);
87
118
  return {
@@ -105,7 +136,10 @@ function operationsForDashScope(provider, transport) {
105
136
  const urls = await transport.generateImageMultimodal({
106
137
  ...common, prompt: input.prompt, size: input.size, n: input.n
107
138
  });
108
- return { kind: 'completed', value: { kind: 'urls', items: urls } };
139
+ return {
140
+ kind: 'completed', value: { kind: 'urls', items: urls },
141
+ artifacts: remoteImageArtifacts(urls)
142
+ };
109
143
  }
110
144
  if (request.capability === 'video') {
111
145
  const remoteTaskId = await transport.submitVideo({
@@ -129,7 +163,11 @@ function operationsForDashScope(provider, transport) {
129
163
  const value = await transport.synthesizeTts({
130
164
  ...common, text: input.text, voice: input.voice
131
165
  });
132
- return { kind: 'completed', value };
166
+ // E2:同步合成产物物化为 Core Artifact——URL 走下载,inline base64 直接落盘。
167
+ const artifacts = value.audioUrl
168
+ ? [{ kind: 'remote-url', url: value.audioUrl, mediaType: 'audio/mpeg' }]
169
+ : [{ kind: 'inline-base64', data: value.audioB64, mediaType: 'audio/mpeg' }];
170
+ return { kind: 'completed', value, artifacts };
133
171
  }
134
172
  throw unsupportedCapability(request.capability, 'dashscope');
135
173
  },
@@ -150,7 +188,13 @@ function operationsForDashScope(provider, transport) {
150
188
  })
151
189
  };
152
190
  }
153
- return { kind: 'succeeded', value: { kind: 'text', text: String(result.text || '') } };
191
+ // E3:转写正文物化为 text/plain Artifact(inline-base64,不再依赖签名 URL 生命周期)。
192
+ const text = String(result.text || '');
193
+ return {
194
+ kind: 'succeeded',
195
+ value: { kind: 'text', text },
196
+ artifacts: [{ kind: 'inline-base64', data: Buffer.from(text, 'utf8').toString('base64'), mediaType: 'text/plain' }]
197
+ };
154
198
  }
155
199
  if (request.capability === 'image' || request.capability === 'video') {
156
200
  const result = await transport.pollTask(common);
@@ -173,17 +217,20 @@ function operationsForDashScope(provider, transport) {
173
217
 
174
218
  async download(request = {}) {
175
219
  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
220
  const targetPath = String(request.targetPath || '').trim();
182
221
  if (!targetPath) {
183
222
  throw new ProviderContractError('download 需要 targetPath', {
184
223
  stage: 'download', category: 'local_io', acceptance: 'accepted'
185
224
  });
186
225
  }
226
+ if (artifact.kind === 'inline-base64') {
227
+ return writeInlineArtifact(artifact, targetPath);
228
+ }
229
+ if (artifact.kind !== 'remote-url' || !artifact.url) {
230
+ throw new ProviderContractError('download 需要 remote-url artifact', {
231
+ stage: 'download', category: 'protocol', acceptance: 'accepted'
232
+ });
233
+ }
187
234
  return {
188
235
  bytes: await transport.downloadTo(artifact.url, targetPath, {
189
236
  signal: request.signal,
@@ -197,11 +244,15 @@ function operationsForDashScope(provider, transport) {
197
244
  }
198
245
 
199
246
  export function createDashScopeProviderAdapter(provider, { transport = defaultTransport } = {}) {
247
+ const { key, baseUrl } = providerIdentity(provider);
200
248
  return defineProviderAdapter({
201
249
  id: providerIdentity(provider).id,
202
250
  protocol: 'dashscope',
203
251
  capabilities: ['image', 'video', 'tts', 'transcribe'],
204
252
  operations: operationsForDashScope(provider, transport),
253
+ async prepareInput({ model, filePath, signal, timeoutMs } = {}) {
254
+ return { url: await transport.uploadTempFile({ key, baseUrl, model, filePath, signal, timeoutMs }) };
255
+ },
205
256
  unsupported: {
206
257
  cancel: '当前 DashScope 媒体协议未提供经过验证的远端取消实现'
207
258
  }
@@ -230,22 +281,26 @@ function operationsForOpenAiImages(provider, transport) {
230
281
  signal: request.signal,
231
282
  ...(request.timeoutMs ? { timeoutMs: request.timeoutMs } : {})
232
283
  });
233
- return { kind: 'completed', value: { kind: 'openai', items } };
284
+ return {
285
+ kind: 'completed', value: { kind: 'openai', items },
286
+ artifacts: openAiImageArtifacts(items)
287
+ };
234
288
  },
235
289
 
236
290
  async download(request = {}) {
237
291
  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
292
  const targetPath = String(request.targetPath || '').trim();
244
293
  if (!targetPath) {
245
294
  throw new ProviderContractError('download 需要 targetPath', {
246
295
  stage: 'download', category: 'local_io', acceptance: 'accepted'
247
296
  });
248
297
  }
298
+ if (artifact.kind === 'inline-base64') return writeInlineArtifact(artifact, targetPath);
299
+ if (artifact.kind !== 'remote-url' || !artifact.url) {
300
+ throw new ProviderContractError('download 需要可物化的图片 artifact', {
301
+ stage: 'download', category: 'protocol', acceptance: 'accepted'
302
+ });
303
+ }
249
304
  return {
250
305
  bytes: await transport.downloadTo(artifact.url, targetPath, {
251
306
  signal: request.signal,
@@ -264,6 +319,7 @@ export function createOpenAiImagesProviderAdapter(provider, { transport = defaul
264
319
  protocol: 'openai-images',
265
320
  capabilities: ['image'],
266
321
  operations: operationsForOpenAiImages(provider, transport),
322
+ unsupportedPreparation: 'OpenAI Images 兼容路径未实现临时输入上传',
267
323
  unsupported: {
268
324
  poll: 'OpenAI Images 兼容路径在当前实现中同步返回结果',
269
325
  cancel: '同步图片响应没有可验证的远端取消任务'
@@ -271,9 +327,16 @@ export function createOpenAiImagesProviderAdapter(provider, { transport = defaul
271
327
  });
272
328
  }
273
329
 
330
+ const PROTOCOL_FACTORIES = new Map([
331
+ ['dashscope', createDashScopeProviderAdapter],
332
+ ['openai-images', createOpenAiImagesProviderAdapter]
333
+ ]);
334
+
274
335
  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));
336
+ const selection = options.protocol
337
+ ? selectMediaProtocol({ ...provider, mediaProtocol: options.protocol, protocolInferred: false })
338
+ : selectMediaProtocol(provider);
339
+ const factory = PROTOCOL_FACTORIES.get(selection.mediaProtocol);
340
+ if (!factory) throw unsupportedProtocolError(selection.mediaProtocol);
341
+ return factory(provider, options);
279
342
  }
@@ -0,0 +1,171 @@
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, providerMediaBaseUrl, isConfiguredProvider } from './provider-protocol.js';
9
+ import { redactProviderMessage } from './provider-contract.js';
10
+
11
+ export class ProviderCatalogError extends Error {
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.name = 'ProviderCatalogError';
15
+ this.code = code;
16
+ }
17
+ }
18
+
19
+ function fail(code, message) {
20
+ throw new ProviderCatalogError(code, message);
21
+ }
22
+
23
+ /** Headless 边界只读取显式配置文件;不推断 DSH_HOME、HOME 或 cwd。 */
24
+ export function loadProviderCatalog(configPath) {
25
+ const supplied = String(configPath || '').trim();
26
+ if (!supplied || !path.isAbsolute(supplied)) {
27
+ fail('IRIS_PROVIDER_CONFIG_INVALID', 'provider-config 必须是显式绝对路径');
28
+ }
29
+ let source;
30
+ try {
31
+ const stat = fs.lstatSync(supplied);
32
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('not a regular file');
33
+ if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) {
34
+ fail('IRIS_PROVIDER_CONFIG_PERMISSIONS', 'provider-config 权限过宽;请限制为仅当前用户可读写');
35
+ }
36
+ source = fs.readFileSync(supplied, 'utf8');
37
+ } catch (error) {
38
+ if (error instanceof ProviderCatalogError) throw error;
39
+ fail('IRIS_PROVIDER_CONFIG_UNREADABLE', 'provider-config 不存在、不是普通文件或无法读取');
40
+ }
41
+ let parsed;
42
+ try { parsed = JSON.parse(source); }
43
+ catch (_) { fail('IRIS_PROVIDER_CONFIG_INVALID', 'provider-config 不是有效 JSON'); }
44
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)
45
+ || !Array.isArray(parsed.providers)
46
+ || (parsed.assignments !== undefined && (!parsed.assignments || typeof parsed.assignments !== 'object'
47
+ || Array.isArray(parsed.assignments)))) {
48
+ fail('IRIS_PROVIDER_CONFIG_INVALID', 'provider-config 结构无效');
49
+ }
50
+ return parsed;
51
+ }
52
+
53
+ const CANDIDATE_CAP_LABEL = Object.freeze({
54
+ [CAPABILITIES.IMAGE]: '图片', [CAPABILITIES.VIDEO]: '视频',
55
+ [CAPABILITIES.TTS]: '语音', [CAPABILITIES.TRANSCRIBE]: '转写'
56
+ });
57
+
58
+ /** 与稳定配置层相同的“手工顺序优先、池序补齐”候选规则(image/video/tts/transcribe 同构)。 */
59
+ function candidatesFromCatalog(catalog, requestedModelRef, capability) {
60
+ const label = CANDIDATE_CAP_LABEL[capability];
61
+ if (!label) fail('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', '当前 Headless 候选链只开放图片、视频、语音与转写能力');
62
+ const providers = catalog.providers.filter(isConfiguredProvider);
63
+ const pool = modelPool(providers).filter((item) => item.capabilities.includes(capability));
64
+ const requested = String(requestedModelRef || '').trim();
65
+ if (requested) {
66
+ const parsed = parseModelRef(requested);
67
+ if (!parsed && requested.includes('::')) fail('IRIS_PROVIDER_MODEL_INVALID', 'model_ref 格式无效');
68
+ const exact = resolvePoolModel(pool, requested, capability);
69
+ if (!exact) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `指定模型不存在、未启用或没有${label}能力`);
70
+ const provider = providers.find((item) => item.id === exact.providerId);
71
+ return Object.freeze([Object.freeze({
72
+ provider, model: exact.id, modelRef: resolveModelRef(provider, capability, requested),
73
+ selectionReason: 'explicit'
74
+ })]);
75
+ }
76
+ const assignment = catalog.assignments?.[capability];
77
+ const ordered = orderedModels(pool, capability, assignment);
78
+ if (!ordered.length) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `provider-config 中没有可用${label}模型`);
79
+ return Object.freeze(ordered.map((item) => Object.freeze({
80
+ provider: providers.find((provider) => provider.id === item.providerId),
81
+ model: item.id,
82
+ modelRef: item.ref,
83
+ selectionReason: selectionReasonForModel(pool, capability, assignment, item)
84
+ })));
85
+ }
86
+
87
+ export function imageCandidatesFromCatalog(catalog, requestedModelRef = '') {
88
+ return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.IMAGE);
89
+ }
90
+
91
+ export function videoCandidatesFromCatalog(catalog, requestedModelRef = '') {
92
+ return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.VIDEO);
93
+ }
94
+
95
+ export function ttsCandidatesFromCatalog(catalog, requestedModelRef = '') {
96
+ return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TTS);
97
+ }
98
+
99
+ export function transcribeCandidatesFromCatalog(catalog, requestedModelRef = '') {
100
+ return candidatesFromCatalog(catalog, requestedModelRef, CAPABILITIES.TRANSCRIBE);
101
+ }
102
+
103
+ /** 只读配置投影;不构造 Adapter、不发现模型、不暴露端点或凭据。 */
104
+ export function providerCatalogSnapshot(catalog) {
105
+ const safeLabel = (value) => value
106
+ ? redactProviderMessage(String(value).replace(/https?:\/\/[^\s<>"']+/gi, '[URL]')) : '';
107
+ return {
108
+ schemaVersion: 1,
109
+ providers: catalog.providers.filter((provider) => provider && typeof provider === 'object').map((provider) => {
110
+ const models = providerModels(provider);
111
+ const selection = selectMediaProtocol(provider);
112
+ return {
113
+ id: safeLabel(provider.id), name: safeLabel(provider.name || provider.id),
114
+ enabled: provider.enabled !== false, configured: isConfiguredProvider(provider),
115
+ auth: provider.auth === 'none' ? 'none' : 'bearer',
116
+ mediaProtocol: safeLabel(selection.mediaProtocol), protocolInferred: selection.protocolInferred,
117
+ capabilities: Object.values(CAPABILITIES).filter((capability) => models.some((model) => model.capabilities.includes(capability))),
118
+ modelCount: models.length
119
+ };
120
+ })
121
+ };
122
+ }
123
+
124
+ export function catalogCapabilitySnapshot(catalog) {
125
+ const pool = modelPool(catalog.providers.filter(isConfiguredProvider));
126
+ return {
127
+ schemaVersion: 1,
128
+ source: 'configuration',
129
+ capabilities: Object.values(CAPABILITIES).map((capability) => {
130
+ const candidates = orderedModels(pool, capability, catalog.assignments?.[capability]).map((model) => model.ref);
131
+ return { capability, candidates, ...(candidates.length ? {} : { gap: 'no_configured_model' }) };
132
+ })
133
+ };
134
+ }
135
+
136
+ /** 非敏感摘要把 Task 绑定到提交时实际使用的媒体端点与协议。 */
137
+ export function providerTaskBinding(provider) {
138
+ const baseUrl = providerMediaBaseUrl(provider);
139
+ const protocol = selectMediaProtocol(provider).mediaProtocol;
140
+ let endpoint;
141
+ try {
142
+ const url = new URL(baseUrl);
143
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('unsafe endpoint');
144
+ if (!['dashscope', 'openai-images'].includes(protocol)) throw new Error('unknown protocol');
145
+ endpoint = protocol === 'dashscope' ? dashscopeApiBase(baseUrl) : url.href.replace(/\/+$/, '');
146
+ } catch (_) { fail('IRIS_PROVIDER_CONFIG_INVALID', 'Provider 媒体端点或协议无效'); }
147
+ return 'sha256:' + crypto.createHash('sha256').update(JSON.stringify([1, protocol, endpoint])).digest('hex');
148
+ }
149
+
150
+ /** 恢复只使用 Task 已保存的 Provider/模型;忽略当前 assignment 与 failover 顺序。 */
151
+ export function providerForTaskFromCatalog(catalog, task) {
152
+ const capability = CAPABILITIES[
153
+ task?.capability === 'video' ? 'VIDEO'
154
+ : task?.capability === 'image' ? 'IMAGE'
155
+ : task?.capability === 'tts' ? 'TTS'
156
+ : task?.capability === 'transcribe' ? 'TRANSCRIBE' : ''
157
+ ];
158
+ if (!capability) fail('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', '当前 Headless 恢复解析只开放图片、视频、语音与转写 Task');
159
+ const label = { video: '视频', tts: '语音', transcribe: '转写' }[task.capability] || '图片';
160
+ const parsed = parseModelRef(task.modelRef);
161
+ if (!parsed || parsed.providerId !== task.providerId) fail('IRIS_PROVIDER_TASK_IDENTITY_MISMATCH', 'Task 的 Provider 与模型身份不一致');
162
+ const matches = catalog.providers.filter((item) => item && item.id === task.providerId);
163
+ if (matches.length > 1) fail('IRIS_PROVIDER_CONFIG_AMBIGUOUS', 'provider-config 存在重复 Provider ID');
164
+ const provider = matches[0];
165
+ if (!provider || provider.enabled === false || (provider.auth !== 'none' && (typeof provider.apiKey !== 'string' || !provider.apiKey.trim()))) fail('IRIS_PROVIDER_TASK_PROVIDER_UNAVAILABLE', 'Task 原 Provider 已移除、停用或缺少凭据');
166
+ if (!task.providerBinding) fail('IRIS_PROVIDER_TASK_BINDING_MISSING', '旧 Task 缺少提交端点绑定;仅支持只读查看,不猜测恢复配置');
167
+ if (providerTaskBinding(provider) !== task.providerBinding) fail('IRIS_PROVIDER_TASK_BINDING_MISMATCH', 'Task 提交端点或协议已改变;拒绝向新端点观察原任务');
168
+ const model = modelPool([provider]).find((item) => item.providerId === parsed.providerId && item.id === parsed.modelId && item.capabilities.includes(capability));
169
+ if (!model) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `Task 原模型已移除或不再具备${label}能力`);
170
+ return provider;
171
+ }