@mokuyoaxis/dsh-iris 0.2.0 → 0.2.2
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 +69 -2
- package/README.en.md +67 -31
- package/README.md +67 -31
- package/bin/dsh-iris.js +222 -21
- package/docs/AGENT_QUICKSTART.md +10 -6
- package/docs/ARCHITECTURE.md +34 -19
- package/docs/ARTIFACT_LIBRARY.md +5 -5
- package/docs/ARTIFACT_MANIFEST.md +1 -1
- package/docs/CLI_MANAGEMENT.md +183 -0
- package/docs/COMPOSITE_VISION.md +35 -0
- package/docs/CORE_RUNTIME_CONTRACT.md +16 -7
- package/docs/DSH_CORE_MIGRATION.md +48 -19
- package/docs/DSH_RC2_ADAPTATION.md +4 -2
- package/docs/FAULT_INJECTION.md +21 -2
- package/docs/HEADLESS_CLI.md +15 -7
- package/docs/HOST_ADAPTER_CONTRACT.md +10 -10
- package/docs/HOST_DOCTOR.md +3 -3
- package/docs/MODEL_PORT_CONTRACT.md +21 -14
- package/docs/OCR_MODEL.md +39 -0
- package/docs/PROMPT_OPTIMIZER.md +77 -0
- package/docs/PROVIDER_ADAPTER_CONTRACT.md +15 -5
- package/docs/PROVIDER_HEALTH.md +22 -5
- package/docs/PROVIDER_RUNTIME_HARNESS.md +1 -1
- package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
- package/docs/ROADMAP.md +37 -19
- package/docs/SECURITY.md +5 -5
- 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/file-access-across-environments.md +8 -5
- package/docs/releases/0.2.1.md +56 -0
- package/docs/releases/0.2.2.md +31 -0
- package/lib/actions.js +199 -286
- package/lib/adapters.js +216 -41
- package/lib/api.js +49 -7
- package/lib/capability.js +23 -16
- package/lib/chromium-browser.js +117 -0
- package/lib/client.js +933 -117
- package/lib/command-service.js +121 -7
- package/lib/composite-vision-routing.js +42 -0
- package/lib/config.js +102 -14
- package/lib/core-artifact-store.js +45 -13
- package/lib/core-lease-recovery.js +64 -92
- package/lib/core-maintenance.js +213 -0
- package/lib/core-runtime.js +45 -47
- package/lib/core-tasks.js +12 -5
- package/lib/core-user-projection.js +1 -0
- package/lib/core-writer-lease.js +134 -0
- package/lib/dsh-core-adapter.js +63 -9
- package/lib/dsh-text-model-adapter.js +222 -0
- package/lib/dsh-vision-model-adapter.js +75 -0
- package/lib/generation-input.js +36 -5
- 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 +205 -175
- 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 +4 -2
- package/lib/model-port-contract.js +13 -5
- package/lib/models.js +29 -42
- package/lib/ocr-model-routing.js +26 -0
- package/lib/ocr.js +110 -64
- package/lib/prompt-optimizer-config.js +21 -10
- package/lib/prompt-optimizer-core.js +247 -0
- package/lib/prompt-optimizer.js +37 -193
- package/lib/provider-adapters.js +87 -10
- package/lib/provider-catalog.js +19 -7
- package/lib/provider-config-service.js +235 -0
- package/lib/provider-contract.js +3 -2
- package/lib/provider-health.js +80 -1
- package/lib/provider-protocol.js +84 -0
- package/lib/provider-task-runner.js +63 -19
- package/lib/summarize.js +31 -20
- package/lib/summary-input.js +52 -0
- package/lib/task-wait.js +40 -0
- 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 +3 -2
- package/lib/workbench-artifacts.js +52 -0
- package/lib/workbench-management.js +151 -0
- package/lib/workbench-zip.js +39 -0
- package/package.json +1 -1
- package/user_guide.md +74 -30
package/lib/adapters.js
CHANGED
|
@@ -3,14 +3,18 @@
|
|
|
3
3
|
* Iris 媒体生成后端 —— 多协议适配器。
|
|
4
4
|
* - dashscope:阿里云百炼(图像/视频异步任务 + qwen-tts 同步)
|
|
5
5
|
* - openai-images:OpenAI Images 兼容协议(generations)
|
|
6
|
+
* - openai-chat-images:网关 Chat Completions 生图扩展(非流式)
|
|
7
|
+
* - openai-responses-images:Responses image_generation 工具及网关图片消息(同步)
|
|
6
8
|
* 所有函数只收显式 key;错误统一 throw Error(人话)。
|
|
7
9
|
*/
|
|
8
10
|
import fs from 'node:fs';
|
|
9
11
|
import path from 'node:path';
|
|
10
12
|
import { Readable } from 'node:stream';
|
|
11
13
|
import { pipeline } from 'node:stream/promises';
|
|
12
|
-
import { dashscopeApiBase, isDashScopeBaseUrl } from './provider-protocol.js';
|
|
14
|
+
import { dashscopeApiBase, imageSizeViolation, isDashScopeBaseUrl, normalizeImageSize } from './provider-protocol.js';
|
|
13
15
|
import { chmodPrivateFile, ensurePrivateDir, privateSibling } from './private-storage.js';
|
|
16
|
+
import { isFreeQuotaExhausted, retryAfterDelay } from './provider-health.js';
|
|
17
|
+
import { ProviderContractError } from './provider-contract.js';
|
|
14
18
|
|
|
15
19
|
/**
|
|
16
20
|
* 网络调用超时档位(2026-09-03 健康检查):挂死的连接绝不能冻住盯守 tick——
|
|
@@ -53,26 +57,31 @@ async function readJson(res) {
|
|
|
53
57
|
* message 保持人话(向后兼容 String(err.message))。
|
|
54
58
|
*/
|
|
55
59
|
export class ProviderError extends Error {
|
|
56
|
-
constructor({ category, status, message, rootCause }) {
|
|
60
|
+
constructor({ category, status, message, rootCause, providerCode, retryAfterMs }) {
|
|
57
61
|
super(message);
|
|
58
62
|
this.name = 'ProviderError';
|
|
59
63
|
this.category = category;
|
|
60
64
|
this.status = status;
|
|
61
65
|
this.rootCause = rootCause;
|
|
66
|
+
this.providerCode = providerCode;
|
|
67
|
+
this.retryAfterMs = retryAfterMs;
|
|
62
68
|
}
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
function classifyHttpError(status, body, fallback) {
|
|
66
|
-
const msg =
|
|
67
|
-
const code = String(
|
|
71
|
+
function classifyHttpError(status, body, fallback, headers) {
|
|
72
|
+
const msg = body?.message || body?.output?.message || body?.error?.message || body?.code || `HTTP ${status}`;
|
|
73
|
+
const code = String(body?.code || body?.error?.code || '');
|
|
68
74
|
let category = 'unknown';
|
|
69
75
|
let text;
|
|
70
|
-
if (status
|
|
76
|
+
if (isFreeQuotaExhausted({ status, code, message: msg })) {
|
|
77
|
+
category = 'quota';
|
|
78
|
+
text = `免费额度耗尽:${msg}`;
|
|
79
|
+
} else if (status === 401 || status === 403) {
|
|
71
80
|
category = 'auth';
|
|
72
81
|
text = `鉴权失败:${msg}`;
|
|
73
82
|
} else if (status === 429) {
|
|
74
|
-
category =
|
|
75
|
-
text =
|
|
83
|
+
category = code === 'BudgetLimitExceeded' ? 'quota' : 'rate_limit';
|
|
84
|
+
text = `${category === 'quota' ? '预算额度耗尽' : '请求限流'}:${msg}`;
|
|
76
85
|
} else if (/InvalidParameter|NotFound\.Model|unsupported/i.test(code)) {
|
|
77
86
|
category = 'invalid_parameter';
|
|
78
87
|
text = `模型或参数不受支持:${msg}`;
|
|
@@ -82,22 +91,71 @@ function classifyHttpError(status, body, fallback) {
|
|
|
82
91
|
} else {
|
|
83
92
|
text = `${fallback}:${msg}`;
|
|
84
93
|
}
|
|
85
|
-
return new ProviderError({ category, status, message: text, rootCause: msg
|
|
94
|
+
return new ProviderError({ category, status, message: text, rootCause: msg, providerCode: code,
|
|
95
|
+
...(status === 429 ? { retryAfterMs: retryAfterDelay(headers?.get('retry-after')) } : {}) });
|
|
86
96
|
}
|
|
87
97
|
|
|
88
|
-
/** 包装 fetch 层的网络异常(无 HTTP
|
|
89
|
-
export function networkError(err) {
|
|
98
|
+
/** 包装 fetch 层的网络异常(无 HTTP 状态)。hint 补一句可执行的排查方向。 */
|
|
99
|
+
export function networkError(err, hint) {
|
|
100
|
+
const code = String(err?.cause?.code || err?.code || '');
|
|
90
101
|
const e = new ProviderError({
|
|
91
102
|
category: 'network',
|
|
92
103
|
status: 0,
|
|
93
|
-
message: '网络错误:' + String((err && err.message) || err),
|
|
94
|
-
rootCause: err
|
|
104
|
+
message: '网络错误:' + String((err && err.message) || err) + (hint ? '(' + hint + ')' : ''),
|
|
105
|
+
rootCause: err,
|
|
106
|
+
...(/^[A-Z][A-Z0-9_]{1,63}$/.test(code) ? { providerCode: code } : {})
|
|
95
107
|
});
|
|
96
108
|
return e;
|
|
97
109
|
}
|
|
98
110
|
|
|
99
|
-
|
|
100
|
-
|
|
111
|
+
/** 超时/取消必须保持原分类:轮询 errStreak 容错与 submitGuard 都依赖 network 与 timeout 的区分。 */
|
|
112
|
+
function isAbortLike(err) {
|
|
113
|
+
return err?.name === 'AbortError' || err?.name === 'TimeoutError' || err?.code === 'ABORT_ERR';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function transportFetchError(err, response) {
|
|
117
|
+
if (isAbortLike(err)) return err;
|
|
118
|
+
const code = String(err?.cause?.code || err?.code || '');
|
|
119
|
+
const detail = {
|
|
120
|
+
ENOTFOUND: 'DNS 未找到目标主机',
|
|
121
|
+
EAI_AGAIN: 'DNS 解析暂时失败',
|
|
122
|
+
ECONNREFUSED: '目标连接被拒绝',
|
|
123
|
+
UND_ERR_CONNECT_TIMEOUT: '连接建立超时',
|
|
124
|
+
UND_ERR_HEADERS_TIMEOUT: '等待响应头超时',
|
|
125
|
+
UND_ERR_BODY_TIMEOUT: '响应正文读取超时',
|
|
126
|
+
ETIMEDOUT: '网络操作超时',
|
|
127
|
+
ECONNRESET: '连接被重置',
|
|
128
|
+
UND_ERR_SOCKET: '底层连接中断',
|
|
129
|
+
CERT_HAS_EXPIRED: 'TLS 证书已过期',
|
|
130
|
+
DEPTH_ZERO_SELF_SIGNED_CERT: 'TLS 证书不受信任',
|
|
131
|
+
UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'TLS 证书链验证失败'
|
|
132
|
+
}[code];
|
|
133
|
+
const phase = response
|
|
134
|
+
? `已收到 HTTP ${response.status} 响应头,读取响应正文失败`
|
|
135
|
+
: '请求传输失败,尚未收到响应头';
|
|
136
|
+
const error = networkError(err, phase + (detail ? ';' + detail : '') +
|
|
137
|
+
(code && /^[A-Z][A-Z0-9_]{1,63}$/.test(code) ? ';' + code : '') + ';受理结果未知,禁止自动重提');
|
|
138
|
+
if (['UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'ETIMEDOUT'].includes(code)) {
|
|
139
|
+
error.category = 'timeout';
|
|
140
|
+
}
|
|
141
|
+
error.stage = response ? 'response' : 'submit';
|
|
142
|
+
error.acceptance = 'unknown';
|
|
143
|
+
return error;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 只包装网络读取;HTTP 业务错误和图片协议校验由调用方处理。 */
|
|
147
|
+
async function fetchImageJson(url, options) {
|
|
148
|
+
let response;
|
|
149
|
+
try {
|
|
150
|
+
response = await fetch(url, options);
|
|
151
|
+
return { res: response, body: await readJson(response) };
|
|
152
|
+
} catch (err) {
|
|
153
|
+
throw transportFetchError(err, response);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function providerError(status, body, fallback, headers) {
|
|
158
|
+
return classifyHttpError(status, body, fallback, headers);
|
|
101
159
|
}
|
|
102
160
|
|
|
103
161
|
/* ---------------- DashScope:图像(旧异步 / 新版多模态) ---------------- */
|
|
@@ -115,21 +173,35 @@ export function dashscopeImageMode(model) {
|
|
|
115
173
|
return 'legacy-async';
|
|
116
174
|
}
|
|
117
175
|
|
|
176
|
+
/**
|
|
177
|
+
* 尺寸在进入请求体前统一「归一 + 校验」:
|
|
178
|
+
* 协议差异(分隔符、数值边界)全部来自 provider-protocol.js,本地违规明确标记为未受理。
|
|
179
|
+
* 归一的意义是跨协议可用 —— 例如把 `1024x1536` 送给 DashScope 多模态(服务端要求 `*`),
|
|
180
|
+
* 或把 `1024*1536` 送给 OpenAI 兼容端点(服务端要求 `x`),都不再需要调用方自己记格式。
|
|
181
|
+
*/
|
|
182
|
+
export function applyImageSize({ protocol, mode, size }) {
|
|
183
|
+
const violation = imageSizeViolation({ protocol, mode, size });
|
|
184
|
+
if (violation) throw new ProviderContractError(violation, {
|
|
185
|
+
stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
|
|
186
|
+
});
|
|
187
|
+
return normalizeImageSize({ protocol, mode, size });
|
|
188
|
+
}
|
|
189
|
+
|
|
118
190
|
/** 新版多模态同步协议:响应中直接返回图片 URL。 */
|
|
119
191
|
export async function generateImageMultimodal({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
|
|
120
192
|
const apiBase = dashscopeApiBase(baseUrl);
|
|
121
|
-
const
|
|
193
|
+
const requestSize = applyImageSize({ protocol: 'dashscope', mode: 'multimodal-sync', size });
|
|
194
|
+
const { res, body } = await fetchImageJson(`${apiBase}/services/aigc/multimodal-generation/generation`, {
|
|
122
195
|
method: 'POST',
|
|
123
196
|
headers: authHeaders(key),
|
|
124
197
|
signal: timeoutSignal(timeoutMs, signal),
|
|
125
198
|
body: JSON.stringify({
|
|
126
199
|
model,
|
|
127
200
|
input: { messages: [{ role: 'user', content: [{ text: prompt }] }] },
|
|
128
|
-
parameters: { ...(
|
|
201
|
+
parameters: { ...(requestSize ? { size: requestSize } : {}), n: Number(n) || 1 }
|
|
129
202
|
})
|
|
130
203
|
});
|
|
131
|
-
|
|
132
|
-
if (!res.ok) throw providerError(res.status, body, '生成失败');
|
|
204
|
+
if (!res.ok) throw providerError(res.status, body, '生成失败', res.headers);
|
|
133
205
|
const urls = [];
|
|
134
206
|
for (const choice of (body && body.output && body.output.choices) || []) {
|
|
135
207
|
for (const item of (choice && choice.message && choice.message.content) || []) {
|
|
@@ -150,14 +222,14 @@ export async function startImageGeneration(opts) {
|
|
|
150
222
|
}
|
|
151
223
|
export async function submitImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SUBMIT, signal }) {
|
|
152
224
|
const apiBase = dashscopeApiBase(baseUrl);
|
|
153
|
-
const
|
|
225
|
+
const requestSize = applyImageSize({ protocol: 'dashscope', mode: 'legacy-async', size });
|
|
226
|
+
const { res, body } = await fetchImageJson(`${apiBase}/services/aigc/text2image/image-synthesis`, {
|
|
154
227
|
method: 'POST',
|
|
155
228
|
headers: { ...authHeaders(key), 'X-DashScope-Async': 'enable' },
|
|
156
229
|
signal: timeoutSignal(timeoutMs, signal),
|
|
157
|
-
body: JSON.stringify({ model, input: { prompt }, parameters: { ...(
|
|
230
|
+
body: JSON.stringify({ model, input: { prompt }, parameters: { ...(requestSize ? { size: requestSize } : {}), n: Number(n) || 1 } })
|
|
158
231
|
});
|
|
159
|
-
|
|
160
|
-
if (!res.ok) throw providerError(res.status, body, '提交失败');
|
|
232
|
+
if (!res.ok) throw providerError(res.status, body, '提交失败', res.headers);
|
|
161
233
|
const taskId = body && body.output && body.output.task_id;
|
|
162
234
|
if (!taskId) throw new Error('提交成功但未返回 task_id');
|
|
163
235
|
return taskId;
|
|
@@ -199,7 +271,7 @@ export async function submitVideo({ key, baseUrl, model, prompt, imgDataUrl, siz
|
|
|
199
271
|
body: JSON.stringify({ model, input, parameters })
|
|
200
272
|
});
|
|
201
273
|
const body = await readJson(res);
|
|
202
|
-
if (!res.ok) throw providerError(res.status, body, '提交失败');
|
|
274
|
+
if (!res.ok) throw providerError(res.status, body, '提交失败', res.headers);
|
|
203
275
|
const taskId = body && body.output && body.output.task_id;
|
|
204
276
|
if (!taskId) throw new Error('提交成功但未返回 task_id');
|
|
205
277
|
return taskId;
|
|
@@ -218,7 +290,7 @@ export async function uploadTempFile({ key, baseUrl, model, filePath, timeoutMs
|
|
|
218
290
|
u.searchParams.set('model', model);
|
|
219
291
|
const pres = await fetch(u, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
|
|
220
292
|
const pbody = await readJson(pres);
|
|
221
|
-
if (!pres.ok || !pbody || !pbody.data) throw providerError(pres.status, pbody, '获取上传凭证失败');
|
|
293
|
+
if (!pres.ok || !pbody || !pbody.data) throw providerError(pres.status, pbody, '获取上传凭证失败', pres.headers);
|
|
222
294
|
const pol = pbody.data;
|
|
223
295
|
const name = path.basename(filePath);
|
|
224
296
|
const keyPath = `${pol.upload_dir}/${Date.now()}-${name}`;
|
|
@@ -246,7 +318,7 @@ export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL,
|
|
|
246
318
|
signal: timeoutSignal(timeoutMs, signal)
|
|
247
319
|
});
|
|
248
320
|
const body = await readJson(res);
|
|
249
|
-
if (!res.ok) throw providerError(res.status, body, '查询任务失败');
|
|
321
|
+
if (!res.ok) throw providerError(res.status, body, '查询任务失败', res.headers);
|
|
250
322
|
const out = (body && body.output) || {};
|
|
251
323
|
const status = out.task_status || 'UNKNOWN';
|
|
252
324
|
const urls = [];
|
|
@@ -291,7 +363,7 @@ export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', t
|
|
|
291
363
|
body: JSON.stringify({ model, input: { text, voice } })
|
|
292
364
|
});
|
|
293
365
|
const body = await readJson(res);
|
|
294
|
-
if (!res.ok) throw providerError(res.status, body, '合成失败');
|
|
366
|
+
if (!res.ok) throw providerError(res.status, body, '合成失败', res.headers);
|
|
295
367
|
const audio = body && body.output && body.output.audio;
|
|
296
368
|
const url = audio && (audio.url || audio.audio_url);
|
|
297
369
|
const b64 = audio && typeof audio.data === 'string' ? audio.data : null;
|
|
@@ -302,24 +374,124 @@ export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', t
|
|
|
302
374
|
/* ---------------- OpenAI Images 兼容(同步 base64/url) ---------------- */
|
|
303
375
|
export async function openAiGenerateImage({ key, baseUrl, model, prompt, size = '1024x1024', n = 1, timeoutMs = T_SYNC_GEN, signal }) {
|
|
304
376
|
const base = String(baseUrl || '').replace(/\/+$/, '');
|
|
305
|
-
const
|
|
377
|
+
const requestSize = applyImageSize({ protocol: 'openai-images', size });
|
|
378
|
+
const { res, body } = await fetchImageJson(`${base}/images/generations`, {
|
|
306
379
|
method: 'POST',
|
|
307
380
|
headers: authHeaders(key),
|
|
308
381
|
signal: timeoutSignal(timeoutMs, signal),
|
|
309
|
-
body: JSON.stringify({ model, prompt, size, n: Number(n) || 1 })
|
|
382
|
+
body: JSON.stringify({ model, prompt, size: requestSize, n: Number(n) || 1 })
|
|
310
383
|
});
|
|
311
|
-
|
|
312
|
-
if (!res.ok) throw providerError(res.status, body, '生成失败');
|
|
384
|
+
if (!res.ok) throw providerError(res.status, body, '生成失败', res.headers);
|
|
313
385
|
const items = (body && Array.isArray(body.data) && body.data) || [];
|
|
314
386
|
const out = [];
|
|
315
387
|
for (const it of items) {
|
|
316
388
|
if (it && typeof it.b64_json === 'string') out.push({ b64: it.b64_json });
|
|
317
389
|
else if (it && typeof it.url === 'string') out.push({ url: it.url });
|
|
318
390
|
}
|
|
319
|
-
if (!out.length) throw
|
|
391
|
+
if (!out.length) throw imageResponseError('生成响应未返回图片数据');
|
|
320
392
|
return out;
|
|
321
393
|
}
|
|
322
394
|
|
|
395
|
+
function imageResponseError(message) {
|
|
396
|
+
return new ProviderContractError(message, {
|
|
397
|
+
stage: 'response', category: 'protocol', acceptance: 'unknown'
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function imageBase64Item(value, mediaType, label) {
|
|
402
|
+
const b64 = typeof value === 'string' ? value.replace(/\s/g, '') : '';
|
|
403
|
+
const bytes = Buffer.from(b64, 'base64');
|
|
404
|
+
if (!bytes.length || bytes.toString('base64').replace(/=+$/, '') !== b64.replace(/=+$/, '')) {
|
|
405
|
+
throw imageResponseError(label + '响应包含无效的图片 base64');
|
|
406
|
+
}
|
|
407
|
+
return { b64: bytes.toString('base64'), ...(mediaType ? { mediaType } : {}) };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** 网关扩展:图片 Markdown/data URL、image_url 内容块或 message.images。 */
|
|
411
|
+
function chatImageItems(body, label = '聊天生图') {
|
|
412
|
+
const items = [], seen = new Set();
|
|
413
|
+
function add(source) {
|
|
414
|
+
if (typeof source !== 'string') return;
|
|
415
|
+
const value = source.trim();
|
|
416
|
+
if (seen.has(value)) return;
|
|
417
|
+
const data = /^data:(image\/[a-z0-9.+-]+);base64,([\s\S]+)$/i.exec(value);
|
|
418
|
+
if (data) {
|
|
419
|
+
items.push(imageBase64Item(data[2], data[1].toLowerCase(), label));
|
|
420
|
+
} else if (/^https?:\/\//i.test(value)) {
|
|
421
|
+
items.push({ url: value });
|
|
422
|
+
} else return;
|
|
423
|
+
seen.add(value);
|
|
424
|
+
}
|
|
425
|
+
function text(content) {
|
|
426
|
+
for (const match of content.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) add(match[1]);
|
|
427
|
+
for (const match of content.matchAll(/data:image\/[a-z0-9.+-]+;base64,[^\s)"<>]+/gi)) add(match[0]);
|
|
428
|
+
}
|
|
429
|
+
function part(item) {
|
|
430
|
+
if (item?.type === 'text' && typeof item.text === 'string') text(item.text);
|
|
431
|
+
else if (item?.image_url) add(typeof item.image_url === 'string' ? item.image_url : item.image_url.url);
|
|
432
|
+
}
|
|
433
|
+
for (const choice of Array.isArray(body?.choices) ? body.choices : []) {
|
|
434
|
+
const message = choice?.message;
|
|
435
|
+
if (typeof message?.content === 'string') text(message.content);
|
|
436
|
+
else if (Array.isArray(message?.content)) message.content.forEach(part);
|
|
437
|
+
if (Array.isArray(message?.images)) message.images.forEach(part);
|
|
438
|
+
}
|
|
439
|
+
if (!items.length) throw imageResponseError(label + '响应未返回图片;受理结果未知,禁止自动重提');
|
|
440
|
+
return items;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export async function openAiChatGenerateImage({ key, baseUrl, model, prompt, image, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
|
|
444
|
+
if (typeof size === 'string' && size.trim()) {
|
|
445
|
+
throw new ProviderContractError('聊天生图协议不支持 size 参数;请在提示词中描述所需尺寸', {
|
|
446
|
+
stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
const base = String(baseUrl || '').replace(/\/+$/, '');
|
|
450
|
+
const content = image ? [
|
|
451
|
+
{ type: 'text', text: prompt },
|
|
452
|
+
{ type: 'image_url', image_url: { url: 'data:' + image.mediaType + ';base64,' + Buffer.from(image.bytes).toString('base64') } }
|
|
453
|
+
] : prompt;
|
|
454
|
+
const { res, body } = await fetchImageJson(`${base}/chat/completions`, {
|
|
455
|
+
method: 'POST', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
|
|
456
|
+
body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content }],
|
|
457
|
+
...(Number(n) > 1 ? { n: Number(n) } : {}) })
|
|
458
|
+
});
|
|
459
|
+
if (!res.ok) throw providerError(res.status, body, '聊天生图失败', res.headers);
|
|
460
|
+
return chatImageItems(body);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** 同步 Responses 生图:主模型由 model 指定,图片工具模型沿用服务端默认。 */
|
|
464
|
+
export async function openAiResponsesGenerateImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
|
|
465
|
+
if (Number(n) !== 1) {
|
|
466
|
+
throw new ProviderContractError('Responses 生图当前只支持 n=1', {
|
|
467
|
+
stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
const base = String(baseUrl || '').replace(/\/+$/, '');
|
|
471
|
+
const requestSize = applyImageSize({ protocol: 'openai-responses-images', size });
|
|
472
|
+
const { res, body } = await fetchImageJson(`${base}/responses`, {
|
|
473
|
+
method: 'POST', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
|
|
474
|
+
body: JSON.stringify({ model, input: prompt, stream: false, store: false, background: false,
|
|
475
|
+
tools: [{ type: 'image_generation', action: 'generate', ...(requestSize ? { size: requestSize } : {}) }],
|
|
476
|
+
tool_choice: { type: 'image_generation' } })
|
|
477
|
+
});
|
|
478
|
+
if (!res.ok) throw providerError(res.status, body, 'Responses 生图失败', res.headers);
|
|
479
|
+
if (body?.status !== 'completed') throw imageResponseError('Responses 生图未返回同步完成结果;禁止自动重提');
|
|
480
|
+
const output = Array.isArray(body.output) ? body.output : [];
|
|
481
|
+
const images = output.filter(item => item?.type === 'image_generation_call');
|
|
482
|
+
if (images.length) return images.map(item => {
|
|
483
|
+
if (item.status !== 'completed') throw imageResponseError('Responses 图片工具未完成;禁止自动重提');
|
|
484
|
+
return imageBase64Item(item.result, undefined, 'Responses 生图');
|
|
485
|
+
});
|
|
486
|
+
// 实测网关将图片作为 output_text 返回;这不证明执行了官方图片工具。
|
|
487
|
+
const messages = output.filter(item => item?.type === 'message');
|
|
488
|
+
if (messages.some(item => item.status !== 'completed')) throw imageResponseError('Responses 图片消息未完成;禁止自动重提');
|
|
489
|
+
return chatImageItems({ choices: messages.map(item => ({ message: {
|
|
490
|
+
content: (Array.isArray(item.content) ? item.content : []).map(part => part?.type === 'output_text'
|
|
491
|
+
? { type: 'text', text: part.text } : part)
|
|
492
|
+
} })) }, 'Responses 生图');
|
|
493
|
+
}
|
|
494
|
+
|
|
323
495
|
/* ---------------- 模型发现(OpenAI GET /models + DashScope GET /api/v1/models) ---------------- */
|
|
324
496
|
/**
|
|
325
497
|
* 拉取供应商模型目录。DashScope 官方端点分页读取并保留 capabilities;
|
|
@@ -342,7 +514,7 @@ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal })
|
|
|
342
514
|
}
|
|
343
515
|
const res = await fetch(url, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
|
|
344
516
|
const body = await readJson(res);
|
|
345
|
-
if (!res.ok) throw providerError(res.status, body, '模型发现失败');
|
|
517
|
+
if (!res.ok) throw providerError(res.status, body, '模型发现失败', res.headers);
|
|
346
518
|
const output = body && body.output;
|
|
347
519
|
const items = (output && Array.isArray(output.models) && output.models)
|
|
348
520
|
|| (body && Array.isArray(body.data) && body.data)
|
|
@@ -358,11 +530,14 @@ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal })
|
|
|
358
530
|
? item.inference_metadata.request_modality.map((value) => String(value).toLowerCase()) : [];
|
|
359
531
|
const responseModalities = Array.isArray(item?.inference_metadata?.response_modality)
|
|
360
532
|
? item.inference_metadata.response_modality.map((value) => String(value).toLowerCase()) : [];
|
|
361
|
-
|
|
362
|
-
if (
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
533
|
+
// 明确能力代码(含空数组/未知新能力)优先,不能被通用模态推断追加用途。
|
|
534
|
+
if (!Array.isArray(item?.capabilities)) {
|
|
535
|
+
if (responseModalities.includes('image')) capabilities.push('IG');
|
|
536
|
+
if (responseModalities.includes('video')) capabilities.push('VG');
|
|
537
|
+
if (requestModalities.includes('audio') && responseModalities.includes('text')) capabilities.push('ASR');
|
|
538
|
+
if (requestModalities.includes('text') && responseModalities.includes('audio')) capabilities.push('TTS');
|
|
539
|
+
if (requestModalities.includes('image') && responseModalities.includes('text')) capabilities.push('VU');
|
|
540
|
+
}
|
|
366
541
|
const normalizedCapabilities = [...new Set(capabilities)];
|
|
367
542
|
found.set(id, normalizedCapabilities.length ? { id, capabilities: normalizedCapabilities } : id);
|
|
368
543
|
}
|
|
@@ -411,7 +586,7 @@ export async function visionStream({ key, baseUrl, model = 'qwen-vl-plus', promp
|
|
|
411
586
|
const text = await res.text().catch(() => '');
|
|
412
587
|
let body = null;
|
|
413
588
|
try { body = JSON.parse(text); } catch (_) { /* keep null */ }
|
|
414
|
-
throw providerError(res.status, body, '视觉理解失败');
|
|
589
|
+
throw providerError(res.status, body, '视觉理解失败', res.headers);
|
|
415
590
|
}
|
|
416
591
|
let full = '';
|
|
417
592
|
const decoder = new TextDecoder();
|
|
@@ -488,7 +663,7 @@ export async function submitTranscription({ key, baseUrl, model = 'qwen-audio-3.
|
|
|
488
663
|
body: JSON.stringify({ model, input })
|
|
489
664
|
});
|
|
490
665
|
const body = await readJson(res);
|
|
491
|
-
if (!res.ok) throw providerError(res.status, body, '提交转写失败');
|
|
666
|
+
if (!res.ok) throw providerError(res.status, body, '提交转写失败', res.headers);
|
|
492
667
|
const taskId = body && body.output && body.output.task_id;
|
|
493
668
|
if (!taskId) throw new Error('提交成功但未返回 task_id');
|
|
494
669
|
return taskId;
|
|
@@ -520,7 +695,7 @@ export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeou
|
|
|
520
695
|
signal: timeoutSignal(timeoutMs, signal)
|
|
521
696
|
});
|
|
522
697
|
const body = await readJson(res);
|
|
523
|
-
if (!res.ok) throw providerError(res.status, body, '查询转写任务失败');
|
|
698
|
+
if (!res.ok) throw providerError(res.status, body, '查询转写任务失败', res.headers);
|
|
524
699
|
const out = (body && body.output) || {};
|
|
525
700
|
const status = out.task_status || 'UNKNOWN';
|
|
526
701
|
let text = '';
|
package/lib/api.js
CHANGED
|
@@ -17,6 +17,9 @@ import { irisHome, capabilitiesOf, onChange as onConfigChange, providerHealthSna
|
|
|
17
17
|
import { mimeOf, mediaUrl } from './media.js';
|
|
18
18
|
import * as tasks from './tasks.js';
|
|
19
19
|
import * as artifacts from './artifacts.js';
|
|
20
|
+
import { workbenchArtifacts } from './workbench-artifacts.js';
|
|
21
|
+
import { workbenchDetail, downloadWorkbenchSelection, deleteWorkbenchArtifacts, workbenchTransactions,
|
|
22
|
+
restoreWorkbenchTransaction, workbenchError } from './workbench-management.js';
|
|
20
23
|
import { runAction } from './actions.js';
|
|
21
24
|
import { chmodPrivateFile, ensurePrivateDir } from './private-storage.js';
|
|
22
25
|
import { selectMediaProtocol } from './provider-protocol.js';
|
|
@@ -25,6 +28,7 @@ import { hostDoctor } from './doctor.js';
|
|
|
25
28
|
import { hostRuntimeEvidence, recordHostClient } from './host-runtime.js';
|
|
26
29
|
import { cancelProviderTaskForDsh, coreSnapshotForDsh, disposeCoreTaskAttention, readCoreArtifactMediaForDsh, redeliverProviderTaskForDsh, reobserveProviderTaskForDsh, retryProviderTaskForDsh } from './dsh-core-adapter.js';
|
|
27
30
|
import { optimizePrompt } from './prompt-optimizer.js';
|
|
31
|
+
import { PROMPT_OPTIMIZER_CAPABILITIES } from './prompt-optimizer-core.js';
|
|
28
32
|
import {
|
|
29
33
|
importPromptOptimizerConfig,
|
|
30
34
|
loadPromptOptimizerConfig,
|
|
@@ -331,6 +335,9 @@ export function serveApi(req, res, host) {
|
|
|
331
335
|
|
|
332
336
|
// POST /iris/api/actions/:name —— GUI 直连触发工具执行
|
|
333
337
|
if (method === 'POST') {
|
|
338
|
+
if (urlPath === '/iris/api/works/download') return handleWorkbenchPost(req, res, downloadWorkbenchSelection, true);
|
|
339
|
+
if (urlPath === '/iris/api/works/delete') return handleWorkbenchPost(req, res, deleteWorkbenchArtifacts);
|
|
340
|
+
if (urlPath === '/iris/api/works/restore') return handleWorkbenchPost(req, res, restoreWorkbenchTransaction);
|
|
334
341
|
if (urlPath === '/iris/api/host-client') {
|
|
335
342
|
if (!host || typeof host !== 'object') return sendJson(res, 503, { error: 'host unavailable' });
|
|
336
343
|
return handleJsonPost(req, res, (body) => recordHostClient(body));
|
|
@@ -362,7 +369,7 @@ export function serveApi(req, res, host) {
|
|
|
362
369
|
return serveSse(res);
|
|
363
370
|
}
|
|
364
371
|
if (urlPath === '/iris/api/prompt-optimizer/config') {
|
|
365
|
-
return sendJson(res, 200, method === 'HEAD' ? {} : loadPromptOptimizerConfig());
|
|
372
|
+
return sendJson(res, 200, method === 'HEAD' ? {} : { ...loadPromptOptimizerConfig(), capabilities: PROMPT_OPTIMIZER_CAPABILITIES });
|
|
366
373
|
}
|
|
367
374
|
if (urlPath === '/iris/api/doctor') {
|
|
368
375
|
if (!host || typeof host !== 'object') return sendJson(res, 503, { error: 'host unavailable' });
|
|
@@ -373,6 +380,12 @@ export function serveApi(req, res, host) {
|
|
|
373
380
|
const query = new URL(req.url || '', 'http://iris.local').searchParams;
|
|
374
381
|
return serveCoreSnapshot(res, { limit: Number(query.get('limit') || 12) });
|
|
375
382
|
}
|
|
383
|
+
if (urlPath === '/iris/api/works') {
|
|
384
|
+
return serveWorkbenchArtifacts(req, method, res);
|
|
385
|
+
}
|
|
386
|
+
if (urlPath === '/iris/api/works/transactions') return serveWorkbenchRead(res, method, () => workbenchTransactions());
|
|
387
|
+
const workDetailMatch = urlPath.match(/^\/iris\/api\/works\/(core|legacy)\/([^/]+)$/);
|
|
388
|
+
if (workDetailMatch) return serveWorkbenchRead(res, method, () => workbenchDetail(workDetailMatch[1], workDetailMatch[2]));
|
|
376
389
|
const coreMediaMatch = urlPath.match(/^\/iris\/api\/core\/artifact\/(artifact_[a-f0-9]{24})\/media$/);
|
|
377
390
|
if (coreMediaMatch) return serveCoreArtifactMedia(coreMediaMatch[1], method, res);
|
|
378
391
|
if (urlPath === '/iris/api/artifacts') {
|
|
@@ -400,6 +413,32 @@ export function serveApi(req, res, host) {
|
|
|
400
413
|
}
|
|
401
414
|
}
|
|
402
415
|
|
|
416
|
+
async function serveWorkbenchArtifacts(req, method, res) {
|
|
417
|
+
const params = new URL(req.url || '', 'http://iris.local').searchParams;
|
|
418
|
+
const input = Object.fromEntries(params);
|
|
419
|
+
for (const key of ['offset', 'limit']) if (input[key] !== undefined) input[key] = Number(input[key]);
|
|
420
|
+
try { return sendJson(res, 200, method === 'HEAD' ? {} : await workbenchArtifacts(input)); }
|
|
421
|
+
catch (error) { return sendJson(res, error.statusCode || 500, { error: '作品读取失败', code: error.code || 'IRIS_GALLERY_FAILED' }); }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function serveWorkbenchRead(res, method, execute) {
|
|
425
|
+
try { return sendJson(res, 200, method === 'HEAD' ? {} : await execute()); }
|
|
426
|
+
catch (error) { const safe = workbenchError(error); return sendJson(res, safe.statusCode || 400, { error: safe.message, code: safe.code }); }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function handleWorkbenchPost(req, res, execute, download = false) {
|
|
430
|
+
return handleJsonPost(req, res, async (body, signal) => {
|
|
431
|
+
try { return await execute(body, { signal }); }
|
|
432
|
+
catch (error) { throw workbenchError(error); }
|
|
433
|
+
}, download ? (response, _status, bytes) => {
|
|
434
|
+
if (response.writableEnded || response.destroyed) return;
|
|
435
|
+
response.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Length': bytes.length,
|
|
436
|
+
'Content-Disposition': 'attachment; filename="iris-works.zip"', 'Cache-Control': 'no-store',
|
|
437
|
+
'X-Content-Type-Options': 'nosniff', 'Cross-Origin-Resource-Policy': 'same-origin' });
|
|
438
|
+
response.end(bytes);
|
|
439
|
+
} : sendJson);
|
|
440
|
+
}
|
|
441
|
+
|
|
403
442
|
async function serveCoreArtifactMedia(artifactId, method, res) {
|
|
404
443
|
try {
|
|
405
444
|
const result = await readCoreArtifactMediaForDsh(artifactId);
|
|
@@ -439,7 +478,7 @@ function sendJson(res, status, obj) {
|
|
|
439
478
|
|
|
440
479
|
/** POST /iris/api/actions/:name:读取 JSON body 调 runAction 返回结果 */
|
|
441
480
|
const MAX_BODY_BYTES = 1e6; // 1MB 上限(防滥用)
|
|
442
|
-
function handleJsonPost(req, res, execute) {
|
|
481
|
+
function handleJsonPost(req, res, execute, respond = sendJson) {
|
|
443
482
|
const chunks = [];
|
|
444
483
|
const controller = new AbortController();
|
|
445
484
|
let completed = false;
|
|
@@ -448,7 +487,7 @@ function handleJsonPost(req, res, execute) {
|
|
|
448
487
|
const finish = (status, body) => {
|
|
449
488
|
if (completed) return;
|
|
450
489
|
completed = true;
|
|
451
|
-
sendJson(res, status, body);
|
|
490
|
+
(status === 200 ? respond : sendJson)(res, status, body);
|
|
452
491
|
};
|
|
453
492
|
const disconnect = () => { if (!completed) controller.abort(); };
|
|
454
493
|
if (typeof req.on === 'function') req.on('aborted', disconnect);
|
|
@@ -469,11 +508,13 @@ function handleJsonPost(req, res, execute) {
|
|
|
469
508
|
const raw = Buffer.concat(chunks).toString('utf8');
|
|
470
509
|
const body = raw ? JSON.parse(raw) : {};
|
|
471
510
|
const result = await execute(body, controller.signal);
|
|
511
|
+
if (controller.signal.aborted) return finish(499, { error: 'request canceled' });
|
|
472
512
|
finish(200, result);
|
|
473
513
|
} catch (err) {
|
|
474
514
|
finish(controller.signal.aborted ? 499 : (Number(err && err.statusCode) || 400), {
|
|
475
515
|
error: controller.signal.aborted ? 'request canceled' : String((err && err.message) || err),
|
|
476
|
-
...(err && err.code ? { code: String(err.code) } : {})
|
|
516
|
+
...(err && err.code ? { code: String(err.code) } : {}),
|
|
517
|
+
...(err && /^delete_[a-f0-9]{24}$/.test(err.transactionId) ? { transactionId: err.transactionId } : {})
|
|
477
518
|
});
|
|
478
519
|
}
|
|
479
520
|
});
|
|
@@ -524,6 +565,7 @@ function handleCoreTaskRetry(taskId, req, res) {
|
|
|
524
565
|
const result = await retryProviderTaskForDsh({
|
|
525
566
|
taskId,
|
|
526
567
|
prompt,
|
|
568
|
+
...(body.source_artifact_id !== undefined ? { sourceArtifactId: body.source_artifact_id } : {}),
|
|
527
569
|
...(body.text !== undefined ? { text: String(body.text) } : {}),
|
|
528
570
|
...(body.voice !== undefined ? { voice: String(body.voice) } : {}),
|
|
529
571
|
...(body.audio_url !== undefined ? { audio_url: String(body.audio_url) } : {}),
|
|
@@ -588,10 +630,10 @@ function handlePromptOptimizer(host, operation, req, res) {
|
|
|
588
630
|
if (operation === 'optimize') return optimizePrompt(host, body, { signal });
|
|
589
631
|
if (operation === 'import') {
|
|
590
632
|
const imported = body && body.config !== undefined ? body.config : body;
|
|
591
|
-
return { ok: true, ...importPromptOptimizerConfig(imported) };
|
|
633
|
+
return { ok: true, ...importPromptOptimizerConfig(imported), capabilities: PROMPT_OPTIMIZER_CAPABILITIES };
|
|
592
634
|
}
|
|
593
|
-
if (operation === 'reset') return { ok: true, ...resetPromptOptimizerConfig() };
|
|
594
|
-
if (operation === 'enabled') return { ok: true, ...setPromptOptimizerEnabled(body && body.enabled) };
|
|
635
|
+
if (operation === 'reset') return { ok: true, ...resetPromptOptimizerConfig(), capabilities: PROMPT_OPTIMIZER_CAPABILITIES };
|
|
636
|
+
if (operation === 'enabled') return { ok: true, ...setPromptOptimizerEnabled(body && body.enabled), capabilities: PROMPT_OPTIMIZER_CAPABILITIES };
|
|
595
637
|
throw new Error('unknown prompt optimizer operation');
|
|
596
638
|
});
|
|
597
639
|
}
|
package/lib/capability.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - 能力常量:image-gen / video-gen / tts / transcribe / vision
|
|
7
7
|
* - 严格选择:不具备某能力的 Provider 不得被兜底选中
|
|
8
8
|
* - 有序 failover:同一能力按 providers.json 声明顺序返回,调用方按序尝试
|
|
9
|
-
* -
|
|
9
|
+
* - 能力只来自明确标签或旧模型字段绑定,不按供应商端点赋能
|
|
10
10
|
*
|
|
11
11
|
* 本模块是纯函数,不读写磁盘;只吃 provider 记录、吐能力判断。
|
|
12
12
|
*/
|
|
@@ -30,36 +30,43 @@ const INFER_FIELD = {
|
|
|
30
30
|
[CAPABILITIES.VISION]: 'visionModel'
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
/** 模型级明确标签优先;旧能力字段只为同名模型提供明确绑定。 */
|
|
34
|
+
export function configuredModelCapabilities(provider, model) {
|
|
35
|
+
if (Array.isArray(model?.capabilities)) {
|
|
36
|
+
return [...new Set(model.capabilities.filter(isKnownCapability))];
|
|
37
|
+
}
|
|
38
|
+
const id = typeof model === 'string' ? model.trim() : String(model?.id || '').trim();
|
|
39
|
+
if (!id) return [];
|
|
40
|
+
const bound = Object.entries(INFER_FIELD)
|
|
41
|
+
.filter(([, field]) => typeof provider?.[field] === 'string' && provider[field].trim() === id)
|
|
42
|
+
.map(([capability]) => capability);
|
|
43
|
+
return Array.isArray(provider?.capabilities) ? bound.filter((capability) => provider.capabilities.includes(capability)) : bound;
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
export function isKnownCapability(capability) {
|
|
34
47
|
return KNOWN.has(capability);
|
|
35
48
|
}
|
|
36
49
|
|
|
37
50
|
/**
|
|
38
51
|
* 一个 provider 的能力列表(去重、只留已知能力)。
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* ② 否则按模型字段推断(imageModel→image-gen 等);
|
|
42
|
-
* ③ 仍为空且是 DashScope 端点 → 全部媒体能力
|
|
43
|
-
* (百炼单一 API 服务 t2i/t2v/tts/compatible-vision,裸账号常见于迁移期)。
|
|
52
|
+
* 显式模型池优先,包括空池;其次是账号明确标签(含空数组),
|
|
53
|
+
* 最后兼容旧能力字段绑定。账号地址和模型名称都不证明能力。
|
|
44
54
|
* @param {object} p provider 记录
|
|
45
55
|
* @returns {string[]}
|
|
46
56
|
*/
|
|
47
57
|
export function capabilitiesOf(p) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
58
|
+
if (Array.isArray(p?.models)) {
|
|
59
|
+
return [...new Set(p.models.flatMap((model) => configuredModelCapabilities(p, model)))];
|
|
60
|
+
}
|
|
61
|
+
if (Array.isArray(p?.capabilities)) {
|
|
62
|
+
return [...new Set(p.capabilities.filter(isKnownCapability))];
|
|
51
63
|
}
|
|
52
64
|
// 旧配置迁移:按模型字段推断(有该能力模型才认为具备该能力)
|
|
53
65
|
const inferred = [];
|
|
54
66
|
for (const [cap, field] of Object.entries(INFER_FIELD)) {
|
|
55
|
-
if (p && p[field]) inferred.push(cap);
|
|
56
|
-
}
|
|
57
|
-
if (inferred.length) return inferred;
|
|
58
|
-
// 裸 DashScope 账号兜底迁移
|
|
59
|
-
if (p && /dashscope/i.test(String(p.baseUrl || ''))) {
|
|
60
|
-
return Object.values(CAPABILITIES);
|
|
67
|
+
if (typeof p?.[field] === 'string' && p[field].trim()) inferred.push(cap);
|
|
61
68
|
}
|
|
62
|
-
return
|
|
69
|
+
return inferred;
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
/**
|