@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.
- package/CHANGELOG.md +54 -1
- package/README.en.md +72 -15
- package/README.md +72 -15
- package/bin/dsh-iris.js +392 -13
- package/docs/AGENT_QUICKSTART.md +35 -0
- package/docs/ARCHITECTURE.md +80 -64
- package/docs/ARTIFACT_MANIFEST.md +60 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +83 -0
- package/docs/DSH_CORE_MIGRATION.md +106 -0
- package/docs/DSH_RC2_ADAPTATION.md +36 -0
- package/docs/HEADLESS_CLI.md +217 -0
- package/docs/HOST_ADAPTER_CONTRACT.md +31 -12
- package/docs/HOST_DOCTOR.md +2 -0
- package/docs/MODEL_PORT_CONTRACT.md +300 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +51 -10
- package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +1 -1
- package/docs/ROADMAP.md +42 -86
- package/docs/SECURITY.md +4 -3
- package/docs/assets/logo/README.md +55 -0
- package/docs/assets/logo/iris-bubble-concept.svg +67 -0
- package/docs/assets/logo/iris-bubble.svg +14 -0
- package/docs/assets/logo/iris-interim-flower.png +0 -0
- package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
- package/docs/assets/logo/iris-logo-mono.svg +78 -0
- package/docs/assets/logo/iris-logo.svg +78 -0
- package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
- package/docs/assets/logo/iris-mark-mono.svg +42 -0
- package/docs/assets/logo/iris-mark.svg +42 -0
- package/docs/releases/0.2.0.md +48 -0
- package/lib/actions.js +374 -362
- package/lib/adapters.js +52 -15
- package/lib/api.js +152 -5
- package/lib/client.js +763 -48
- package/lib/command-service.js +420 -0
- package/lib/config.js +56 -43
- package/lib/core-artifact-store.js +598 -0
- package/lib/core-artifacts.js +4 -0
- package/lib/core-attention.js +103 -0
- package/lib/core-contract.js +105 -0
- package/lib/core-doctor.js +41 -0
- package/lib/core-lease-recovery.js +170 -0
- package/lib/core-runtime.js +232 -0
- package/lib/core-tasks.js +560 -0
- package/lib/core-user-projection.js +192 -0
- package/lib/doctor.js +92 -33
- package/lib/dsh-core-adapter.js +599 -0
- package/lib/dsh-host-adapter.js +58 -7
- package/lib/generation-input.js +95 -0
- package/lib/guard.js +3 -3
- package/lib/index.js +159 -54
- package/lib/model-invoker.js +218 -0
- package/lib/model-port-contract.js +333 -0
- package/lib/models.js +97 -11
- package/lib/provider-adapter.js +49 -23
- package/lib/provider-adapters.js +84 -21
- package/lib/provider-catalog.js +171 -0
- package/lib/provider-contract.js +72 -3
- package/lib/provider-protocol.js +51 -2
- package/lib/provider-task-runner.js +328 -0
- package/lib/tasks.js +16 -4
- package/lib/vision.js +28 -7
- package/package.json +8 -8
- 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
|
-
*
|
|
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
|
-
/**
|
|
66
|
-
const
|
|
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
|
-
|
|
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 = [];
|
package/lib/provider-adapter.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
}
|
package/lib/provider-adapters.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
19
|
-
const baseUrl =
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
+
}
|