@mokuyoaxis/dsh-iris 0.1.3 → 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 (91) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +23 -49
  2. package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +30 -0
  3. package/.dsh/skills/iris-compose-media/references/workflows.md +38 -0
  4. package/.dsh/skills/iris-verify-ui/SKILL.md +3 -25
  5. package/.dsh/skills/iris-verify-ui/agents/openai.yaml +4 -0
  6. package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +31 -0
  7. package/CHANGELOG.md +77 -2
  8. package/README.en.md +98 -19
  9. package/README.md +98 -19
  10. package/bin/dsh-iris.js +392 -13
  11. package/docs/AGENT_QUICKSTART.md +35 -0
  12. package/docs/ARCHITECTURE.md +82 -52
  13. package/docs/ARTIFACT_LIBRARY.md +24 -0
  14. package/docs/ARTIFACT_MANIFEST.md +60 -0
  15. package/docs/CORE_RUNTIME_CONTRACT.md +83 -0
  16. package/docs/DSH_CORE_MIGRATION.md +106 -0
  17. package/docs/DSH_RC2_ADAPTATION.md +36 -0
  18. package/docs/HEADLESS_CLI.md +217 -0
  19. package/docs/HOST_ADAPTER_CONTRACT.md +105 -0
  20. package/docs/HOST_DOCTOR.md +49 -0
  21. package/docs/MODEL_PORT_CONTRACT.md +300 -0
  22. package/docs/PROVIDER_ADAPTER_CONTRACT.md +138 -0
  23. package/docs/PROVIDER_HEALTH.md +39 -0
  24. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  25. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  26. package/docs/ROADMAP.md +42 -76
  27. package/docs/SECURITY.md +10 -5
  28. package/docs/TASK_SEMANTICS.md +3 -2
  29. package/docs/assets/diagrams/iris-architecture.png +0 -0
  30. package/docs/assets/diagrams/iris-architecture.svg +5 -5
  31. package/docs/assets/logo/README.md +55 -0
  32. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  33. package/docs/assets/logo/iris-bubble.svg +14 -0
  34. package/docs/assets/logo/iris-interim-flower.png +0 -0
  35. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  36. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  37. package/docs/assets/logo/iris-logo.svg +78 -0
  38. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  39. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  40. package/docs/assets/logo/iris-mark.svg +42 -0
  41. package/docs/releases/0.1.4.md +59 -0
  42. package/docs/releases/0.2.0.md +48 -0
  43. package/lib/actions.js +673 -525
  44. package/lib/adapters.js +58 -21
  45. package/lib/api.js +182 -16
  46. package/lib/artifacts.js +239 -0
  47. package/lib/bundled-skills.js +5 -5
  48. package/lib/client.js +1116 -86
  49. package/lib/command-service.js +420 -0
  50. package/lib/config.js +245 -49
  51. package/lib/core-artifact-store.js +598 -0
  52. package/lib/core-artifacts.js +4 -0
  53. package/lib/core-attention.js +103 -0
  54. package/lib/core-contract.js +105 -0
  55. package/lib/core-doctor.js +41 -0
  56. package/lib/core-lease-recovery.js +170 -0
  57. package/lib/core-runtime.js +232 -0
  58. package/lib/core-tasks.js +560 -0
  59. package/lib/core-user-projection.js +192 -0
  60. package/lib/doctor.js +260 -30
  61. package/lib/dsh-core-adapter.js +599 -0
  62. package/lib/dsh-host-adapter.js +240 -0
  63. package/lib/generation-input.js +95 -0
  64. package/lib/guard.js +3 -3
  65. package/lib/host-contract.js +131 -0
  66. package/lib/host-runtime.js +156 -0
  67. package/lib/index.js +255 -166
  68. package/lib/locate.js +1 -1
  69. package/lib/media.js +16 -6
  70. package/lib/model-invoker.js +218 -0
  71. package/lib/model-port-contract.js +333 -0
  72. package/lib/models.js +97 -11
  73. package/lib/prompt-optimizer.js +10 -20
  74. package/lib/provider-adapter.js +397 -0
  75. package/lib/provider-adapters.js +342 -0
  76. package/lib/provider-catalog.js +171 -0
  77. package/lib/provider-contract.js +72 -3
  78. package/lib/provider-health.js +59 -0
  79. package/lib/provider-protocol.js +51 -2
  80. package/lib/provider-task-runner.js +328 -0
  81. package/lib/render.js +43 -12
  82. package/lib/tasks.js +45 -9
  83. package/lib/vision.js +51 -56
  84. package/package.json +20 -14
  85. package/user_guide.md +67 -12
  86. package/docs/assets/diagrams/iris-architecture.drawio +0 -1
  87. package/docs/assets/diagrams/iris-task-lifecycle.drawio +0 -1
  88. package/docs/assets/diagrams/iris-workflow-compose.drawio +0 -1
  89. package/docs/releases/0.1.1.md +0 -51
  90. package/docs/releases/0.1.2.md +0 -26
  91. package/docs/releases/0.1.3.md +0 -30
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 = [];
@@ -2,21 +2,11 @@
2
2
  /** DSH 对话框级提示词优化:只处理显式提交的草稿,不读取会话历史或附件。 */
3
3
  import crypto from 'node:crypto';
4
4
  import { loadPromptOptimizerConfig, PROMPT_TARGETS } from './prompt-optimizer-config.js';
5
+ import { hasHostPort, requireHostPort } from './host-contract.js';
5
6
 
6
7
  export const MAX_PROMPT_INPUT_BYTES = 32 * 1024;
7
8
  export const MAX_PROMPT_OUTPUT_CHARS = 16000;
8
9
 
9
- function service(ctx, name) {
10
- if (!ctx) return undefined;
11
- if (typeof ctx.get === 'function') {
12
- try {
13
- const value = ctx.get(name);
14
- if (value) return value;
15
- } catch (_) { /* 未注入的可选服务 */ }
16
- }
17
- try { return ctx[name]; } catch (_) { return undefined; }
18
- }
19
-
20
10
  function modelSelection(value, field) {
21
11
  if (!value || typeof value !== 'object') return null;
22
12
  const provider = typeof value.provider === 'string' ? value.provider.trim() : '';
@@ -32,15 +22,15 @@ function modelSelection(value, field) {
32
22
  return out;
33
23
  }
34
24
 
35
- export function resolvePromptOptimizerRoute(ctx, config, requestedRoute) {
25
+ export function resolvePromptOptimizerRoute(host, config, requestedRoute) {
36
26
  if (config.route.mode === 'fixed') {
37
27
  return { ...modelSelection(config.route, 'route'), source: 'iris-fixed' };
38
28
  }
39
29
  const current = modelSelection(requestedRoute);
40
30
  if (current) return { ...current, source: 'current-session' };
41
- const defaults = service(ctx, 'agentDefaultModel');
42
- const fallback = defaults && typeof defaults.currentSelection === 'function'
43
- ? modelSelection(defaults.currentSelection())
31
+ const textModel = hasHostPort(host, 'textModel') ? host.ports.textModel : undefined;
32
+ const fallback = textModel && typeof textModel.currentSelection === 'function'
33
+ ? modelSelection(textModel.currentSelection())
44
34
  : null;
45
35
  if (fallback) return { ...fallback, source: 'host-default' };
46
36
  throw new Error('Iris 找不到可用文本模型:请先在 DSH 会话中选择模型,或在 JSON 配置中设置 fixed 路由');
@@ -131,11 +121,11 @@ function cleanOutput(value) {
131
121
 
132
122
  /**
133
123
  * 执行一次显式的提示词优化。
134
- * @param {object} ctx DSH Host ctx
124
+ * @param {object} host Iris Host Adapter
135
125
  * @param {object} input {text,target,route,sessionId}
136
126
  * @param {{signal?:AbortSignal}} options
137
127
  */
138
- export async function optimizePrompt(ctx, input, { signal } = {}) {
128
+ export async function optimizePrompt(host, input, { signal } = {}) {
139
129
  const original = typeof input?.text === 'string' ? input.text : '';
140
130
  const text = original.trim();
141
131
  if (!text) throw new Error('请输入需要优化的提示词');
@@ -148,9 +138,9 @@ export async function optimizePrompt(ctx, input, { signal } = {}) {
148
138
 
149
139
  const loaded = loadPromptOptimizerConfig();
150
140
  const config = loaded.config;
151
- const route = resolvePromptOptimizerRoute(ctx, config, input && input.route);
152
- const llm = service(ctx, 'llm');
153
- if (!llm || typeof llm.stream !== 'function') throw new Error('DSH LLM 服务不可用,无法优化提示词');
141
+ const route = resolvePromptOptimizerRoute(host, config, input && input.route);
142
+ const llm = requireHostPort(host, 'textModel', '提示词优化');
143
+ if (typeof llm.stream !== 'function') throw new Error('Iris Host textModel 缺少 stream,无法优化提示词');
154
144
 
155
145
  const deadline = composeSignal(signal, config.generation.timeoutMs);
156
146
  const partials = new Map();
@@ -0,0 +1,397 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Iris Provider Adapter v0 完整生命周期契约。
5
+ *
6
+ * 本模块不读取配置、不调用网络、不创建 Task,也不依赖 DSH。具体供应商只把
7
+ * discovery/submit/poll/cancel/download/error mapping 映射到这些稳定结果。
8
+ */
9
+ import {
10
+ ProviderContractError,
11
+ normalizeProviderArtifacts,
12
+ normalizeSubmissionResult,
13
+ providerErrorRecord,
14
+ resultFromThrown
15
+ } from './provider-contract.js';
16
+
17
+ export const PROVIDER_ADAPTER_CONTRACT_VERSION = 0;
18
+
19
+ export const PROVIDER_OPERATIONS = Object.freeze([
20
+ 'discover',
21
+ 'submit',
22
+ 'poll',
23
+ 'cancel',
24
+ 'download',
25
+ 'mapError'
26
+ ]);
27
+
28
+ export const PROVIDER_TASK_CAPABILITIES = Object.freeze([
29
+ 'image',
30
+ 'video',
31
+ 'tts',
32
+ 'transcribe'
33
+ ]);
34
+
35
+ export const PROVIDER_POLL_KINDS = Object.freeze([
36
+ 'pending',
37
+ 'succeeded',
38
+ 'failed',
39
+ 'canceled',
40
+ 'unknown'
41
+ ]);
42
+
43
+ export const PROVIDER_CANCEL_KINDS = Object.freeze([
44
+ 'canceled',
45
+ 'not_supported',
46
+ 'unknown'
47
+ ]);
48
+
49
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
50
+ const PROTOCOL = /^[a-z0-9][a-z0-9._-]*$/;
51
+
52
+ function isPlainObject(value) {
53
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
54
+ }
55
+
56
+ function validateOperation(name) {
57
+ if (!PROVIDER_OPERATIONS.includes(name)) throw new Error('未知 Provider 操作:' + String(name));
58
+ }
59
+
60
+ function normalizedReason(value, fallback) {
61
+ const text = String(value || fallback || '').trim();
62
+ if (!text) throw new TypeError('Provider unsupported 原因不能为空');
63
+ return text.slice(0, 500);
64
+ }
65
+
66
+ /**
67
+ * 建立只读 Adapter 描述。每个生命周期操作必须恰好出现在 operations 或
68
+ * unsupported 之一,避免“方法不存在”被误解成临时故障。
69
+ */
70
+ export function defineProviderAdapter(input) {
71
+ if (!isPlainObject(input)) throw new TypeError('Provider Adapter 必须是对象');
72
+ const allowed = new Set([
73
+ 'contractVersion', 'id', 'protocol', 'capabilities', 'operations', 'unsupported',
74
+ 'prepareInput', 'unsupportedPreparation'
75
+ ]);
76
+ for (const key of Object.keys(input)) {
77
+ if (!allowed.has(key)) throw new Error('Provider Adapter 不允许顶层字段:' + key);
78
+ }
79
+
80
+ const contractVersion = input.contractVersion ?? PROVIDER_ADAPTER_CONTRACT_VERSION;
81
+ if (contractVersion !== PROVIDER_ADAPTER_CONTRACT_VERSION) {
82
+ throw new Error('不支持的 Provider Adapter 契约版本:' + String(contractVersion));
83
+ }
84
+ const id = String(input.id || '').trim();
85
+ const protocol = String(input.protocol || '').trim();
86
+ if (!ID.test(id)) throw new Error('Provider Adapter id 必须是稳定标识');
87
+ if (!PROTOCOL.test(protocol)) throw new Error('Provider Adapter protocol 必须是小写稳定标识');
88
+
89
+ const capabilities = [...new Set(Array.isArray(input.capabilities) ? input.capabilities : [])];
90
+ if (!capabilities.length || capabilities.some((item) => !PROVIDER_TASK_CAPABILITIES.includes(item))) {
91
+ throw new Error('Provider Adapter capabilities 必须使用已知媒体任务能力');
92
+ }
93
+
94
+ const supplied = input.operations ?? {};
95
+ const missing = input.unsupported ?? {};
96
+ if (!isPlainObject(supplied) || !isPlainObject(missing)) {
97
+ throw new TypeError('Provider Adapter operations/unsupported 必须是对象');
98
+ }
99
+
100
+ const operations = {};
101
+ const unsupported = {};
102
+ for (const name of Object.keys(supplied)) validateOperation(name);
103
+ for (const name of Object.keys(missing)) validateOperation(name);
104
+ for (const name of PROVIDER_OPERATIONS) {
105
+ const hasOperation = Object.prototype.hasOwnProperty.call(supplied, name);
106
+ const hasUnsupported = Object.prototype.hasOwnProperty.call(missing, name);
107
+ if (hasOperation === hasUnsupported) {
108
+ throw new Error('Provider 操作 ' + name + ' 必须恰好声明为 supported 或 unsupported');
109
+ }
110
+ if (hasOperation) {
111
+ if (typeof supplied[name] !== 'function') {
112
+ throw new TypeError('Provider 操作 ' + name + ' 必须是函数');
113
+ }
114
+ operations[name] = supplied[name];
115
+ } else {
116
+ unsupported[name] = Object.freeze({ reason: normalizedReason(missing[name], name + ' 未实现') });
117
+ }
118
+ }
119
+ if (!operations.submit || !operations.mapError) {
120
+ throw new Error('Provider Adapter 必须实现 submit 与 mapError');
121
+ }
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
+
132
+ return Object.freeze({
133
+ contractVersion,
134
+ id,
135
+ protocol,
136
+ capabilities: Object.freeze(capabilities),
137
+ operations: Object.freeze(operations),
138
+ unsupported: Object.freeze(unsupported),
139
+ prepareInput: input.prepareInput,
140
+ unsupportedPreparation
141
+ });
142
+ }
143
+
144
+ export class ProviderOperationError extends Error {
145
+ constructor(adapter, operation) {
146
+ validateOperation(operation);
147
+ const providerId = adapter?.id || 'unknown-provider';
148
+ const reason = adapter?.unsupported?.[operation]?.reason || '操作未实现';
149
+ super('Iris Provider ' + providerId + ' 不支持 ' + operation + ':' + reason);
150
+ this.name = 'ProviderOperationError';
151
+ this.code = 'IRIS_PROVIDER_OPERATION_UNSUPPORTED';
152
+ this.providerId = providerId;
153
+ this.protocol = adapter?.protocol || 'unknown';
154
+ this.operation = operation;
155
+ }
156
+ }
157
+
158
+ export function hasProviderOperation(adapter, operation) {
159
+ validateOperation(operation);
160
+ return Boolean(adapter?.operations && typeof adapter.operations[operation] === 'function');
161
+ }
162
+
163
+ export function requireProviderOperation(adapter, operation) {
164
+ if (!hasProviderOperation(adapter, operation)) throw new ProviderOperationError(adapter, operation);
165
+ return adapter.operations[operation];
166
+ }
167
+
168
+ /** 只输出可序列化能力事实;不会暴露闭包中的 key、baseUrl 或 live transport。 */
169
+ export function providerAdapterSnapshot(adapter) {
170
+ const operations = {};
171
+ for (const name of PROVIDER_OPERATIONS) {
172
+ operations[name] = hasProviderOperation(adapter, name)
173
+ ? Object.freeze({ status: 'supported' })
174
+ : Object.freeze({
175
+ status: 'unsupported',
176
+ reason: adapter?.unsupported?.[name]?.reason || '操作未实现'
177
+ });
178
+ }
179
+ return Object.freeze({
180
+ contractVersion: PROVIDER_ADAPTER_CONTRACT_VERSION,
181
+ provider: Object.freeze({
182
+ id: adapter?.id || 'unknown-provider',
183
+ protocol: adapter?.protocol || 'unknown'
184
+ }),
185
+ capabilities: Object.freeze([...(adapter?.capabilities || [])]),
186
+ operations: Object.freeze(operations),
187
+ inputPreparation: Object.freeze(typeof adapter?.prepareInput === 'function'
188
+ ? { status: 'supported' }
189
+ : { status: 'unsupported', reason: adapter?.unsupportedPreparation || '协议未实现临时输入上传' })
190
+ });
191
+ }
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
+
220
+ export function normalizeDiscoveryResult(value) {
221
+ const source = Array.isArray(value) ? value : value?.models;
222
+ if (!Array.isArray(source)) throw new TypeError('Provider discover 必须返回 models 数组');
223
+ const models = [];
224
+ const seen = new Set();
225
+ for (const item of source) {
226
+ const id = String(typeof item === 'string' ? item : item?.id || '').trim();
227
+ if (!id || seen.has(id)) continue;
228
+ seen.add(id);
229
+ const model = { id };
230
+ if (Array.isArray(item?.capabilities)) {
231
+ const capabilities = [...new Set(item.capabilities.filter((cap) => typeof cap === 'string' && cap))];
232
+ if (capabilities.length) model.capabilities = Object.freeze(capabilities);
233
+ }
234
+ models.push(Object.freeze(model));
235
+ }
236
+ return Object.freeze({ models: Object.freeze(models) });
237
+ }
238
+
239
+ /**
240
+ * poll 标准结果。兼容旧 {done,ok,urls,text,status,message} 仅用于 0.1.x 渐进迁移;
241
+ * 新 Adapter 必须直接返回 kind 结果。
242
+ */
243
+ export function normalizeProviderPollResult(value) {
244
+ if (!isPlainObject(value)) throw new TypeError('Provider poll 必须返回结构化结果');
245
+
246
+ if (!PROVIDER_POLL_KINDS.includes(value.kind) && typeof value.done === 'boolean') {
247
+ if (!value.done) return Object.freeze({
248
+ kind: 'pending',
249
+ ...(value.status ? { progress: String(value.status) } : {})
250
+ });
251
+ if (value.ok) {
252
+ return Object.freeze({
253
+ kind: 'succeeded',
254
+ artifacts: normalizeProviderArtifacts((value.urls || []).map((url) => ({ kind: 'remote-url', url }))),
255
+ ...(typeof value.text === 'string'
256
+ ? { value: Object.freeze({ kind: 'text', text: value.text }) }
257
+ : {})
258
+ });
259
+ }
260
+ return Object.freeze({
261
+ kind: 'failed',
262
+ error: Object.freeze(providerErrorRecord(value.message || '供应商任务失败', {
263
+ stage: 'poll', category: 'provider', acceptance: 'accepted'
264
+ }))
265
+ });
266
+ }
267
+
268
+ if (!PROVIDER_POLL_KINDS.includes(value.kind)) {
269
+ throw new TypeError('未知 Provider poll kind:' + String(value.kind));
270
+ }
271
+ if (value.kind === 'pending') {
272
+ return Object.freeze({
273
+ kind: 'pending',
274
+ ...(value.progress !== undefined ? { progress: String(value.progress) } : {})
275
+ });
276
+ }
277
+ if (value.kind === 'succeeded') {
278
+ return Object.freeze({
279
+ kind: 'succeeded',
280
+ artifacts: normalizeProviderArtifacts(value.artifacts),
281
+ ...(value.value !== undefined ? { value: value.value } : {})
282
+ });
283
+ }
284
+ const stage = value.kind === 'canceled' ? 'cancel' : 'poll';
285
+ return Object.freeze({
286
+ kind: value.kind,
287
+ error: Object.freeze(providerErrorRecord(value.error || value.message || ('Provider poll ' + value.kind), {
288
+ stage,
289
+ category: value.kind === 'canceled' ? 'aborted' : undefined,
290
+ acceptance: 'accepted'
291
+ }))
292
+ });
293
+ }
294
+
295
+ export function normalizeProviderCancelResult(value) {
296
+ if (!isPlainObject(value) || !PROVIDER_CANCEL_KINDS.includes(value.kind)) {
297
+ throw new TypeError('Provider cancel 必须返回 canceled/not_supported/unknown');
298
+ }
299
+ if (value.kind === 'canceled') return Object.freeze({ kind: 'canceled' });
300
+ if (value.kind === 'not_supported') {
301
+ return Object.freeze({ kind: 'not_supported', reason: normalizedReason(value.reason, '供应商不支持远端取消') });
302
+ }
303
+ return Object.freeze({
304
+ kind: 'unknown',
305
+ error: Object.freeze(providerErrorRecord(value.error || value.message || '远端取消结果未知', {
306
+ stage: 'cancel', acceptance: 'accepted'
307
+ }))
308
+ });
309
+ }
310
+
311
+ export function normalizeProviderDownloadResult(value) {
312
+ const bytes = Number(typeof value === 'number' ? value : value?.bytes);
313
+ if (!Number.isSafeInteger(bytes) || bytes < 0) {
314
+ throw new TypeError('Provider download 必须返回非负安全整数 bytes');
315
+ }
316
+ return Object.freeze({ bytes });
317
+ }
318
+
319
+ function mappedError(adapter, error, context) {
320
+ const mapper = requireProviderOperation(adapter, 'mapError');
321
+ let mapped;
322
+ try {
323
+ mapped = mapper(error, context);
324
+ } catch (mappingError) {
325
+ mapped = mappingError;
326
+ }
327
+ const source = mapped || error;
328
+ const defaults = {};
329
+ for (const key of ['stage', 'category', 'acceptance', 'retryable']) {
330
+ if (source == null || source[key] === undefined) defaults[key] = context[key];
331
+ }
332
+ return providerErrorRecord(source, defaults);
333
+ }
334
+
335
+ function operationErrorContext(operation, context) {
336
+ const supplied = isPlainObject(context) ? context : {};
337
+ if (operation === 'submit') return { stage: 'submit', acceptance: 'unknown', ...supplied };
338
+ if (operation === 'discover') return { stage: 'validate', acceptance: 'not_accepted', ...supplied };
339
+ if (operation === 'poll') return { stage: 'poll', acceptance: 'accepted', ...supplied };
340
+ if (operation === 'cancel') return { stage: 'cancel', acceptance: 'accepted', ...supplied };
341
+ if (operation === 'download') return { stage: 'download', acceptance: 'accepted', ...supplied };
342
+ return supplied;
343
+ }
344
+
345
+ /**
346
+ * 生命周期统一调用入口。submit 异常转为保守四态结果;cancel 异常转为 unknown;
347
+ * poll/download/discover 保持抛错,让调度器应用各自的重试和事实规则。
348
+ */
349
+ export async function invokeProviderOperation(adapter, operation, input, context = {}) {
350
+ validateOperation(operation);
351
+ if (operation === 'mapError') {
352
+ return mappedError(adapter, input, context);
353
+ }
354
+ const fn = requireProviderOperation(adapter, operation);
355
+ const errorContext = operationErrorContext(operation, context);
356
+ let value;
357
+ try {
358
+ value = await fn(input, context);
359
+ } catch (error) {
360
+ const evidenceContext = { ...errorContext };
361
+ for (const key of ['stage', 'category', 'acceptance', 'retryable']) {
362
+ if (error && error[key] !== undefined) evidenceContext[key] = error[key];
363
+ }
364
+ const safe = mappedError(adapter, error, evidenceContext);
365
+ if (operation === 'submit') return resultFromThrown(safe);
366
+ if (operation === 'cancel') return normalizeProviderCancelResult({ kind: 'unknown', error: safe });
367
+ throw new ProviderContractError(safe.safeMessage, safe);
368
+ }
369
+
370
+ if (operation === 'discover') return normalizeDiscoveryResult(value);
371
+ if (operation === 'submit') return normalizeSubmissionResult(value);
372
+ if (operation === 'poll') return normalizeProviderPollResult(value);
373
+ if (operation === 'cancel') return normalizeProviderCancelResult(value);
374
+ if (operation === 'download') return normalizeProviderDownloadResult(value);
375
+ throw new Error('未处理的 Provider 操作:' + operation);
376
+ }
377
+
378
+ /** 0.1.x 任务观察器兼容视图;0.2.0 Command 完全消费 canonical poll 后可删除。 */
379
+ export function providerPollTaskView(value) {
380
+ const result = normalizeProviderPollResult(value);
381
+ if (result.kind === 'pending') {
382
+ return { kind: result.kind, done: false, ok: false, urls: [], status: result.progress || 'running' };
383
+ }
384
+ if (result.kind === 'succeeded') {
385
+ const urls = result.artifacts.filter((item) => item.kind === 'remote-url').map((item) => item.url);
386
+ if (result.value?.kind === 'text') urls.push(String(result.value.text || ''));
387
+ return { kind: result.kind, done: true, ok: true, urls, value: result.value };
388
+ }
389
+ return {
390
+ kind: result.kind,
391
+ done: true,
392
+ ok: false,
393
+ urls: [],
394
+ message: result.error.safeMessage,
395
+ error: result.error
396
+ };
397
+ }