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