@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/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 } from './provider-protocol.js';
|
|
14
|
+
import { dashscopeApiBase, isDashScopeBaseUrl } 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——
|
|
@@ -31,7 +35,10 @@ function timeoutSignal(ms, external) {
|
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
function authHeaders(key) {
|
|
34
|
-
return {
|
|
38
|
+
return {
|
|
39
|
+
...(typeof key === 'string' && key.trim() ? { Authorization: `Bearer ${key}` } : {}),
|
|
40
|
+
'Content-Type': 'application/json'
|
|
41
|
+
};
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
async function readJson(res) {
|
|
@@ -50,26 +57,31 @@ async function readJson(res) {
|
|
|
50
57
|
* message 保持人话(向后兼容 String(err.message))。
|
|
51
58
|
*/
|
|
52
59
|
export class ProviderError extends Error {
|
|
53
|
-
constructor({ category, status, message, rootCause }) {
|
|
60
|
+
constructor({ category, status, message, rootCause, providerCode, retryAfterMs }) {
|
|
54
61
|
super(message);
|
|
55
62
|
this.name = 'ProviderError';
|
|
56
63
|
this.category = category;
|
|
57
64
|
this.status = status;
|
|
58
65
|
this.rootCause = rootCause;
|
|
66
|
+
this.providerCode = providerCode;
|
|
67
|
+
this.retryAfterMs = retryAfterMs;
|
|
59
68
|
}
|
|
60
69
|
}
|
|
61
70
|
|
|
62
|
-
function classifyHttpError(status, body, fallback) {
|
|
63
|
-
const msg =
|
|
64
|
-
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 || '');
|
|
65
74
|
let category = 'unknown';
|
|
66
75
|
let text;
|
|
67
|
-
if (status
|
|
76
|
+
if (isFreeQuotaExhausted({ status, code, message: msg })) {
|
|
77
|
+
category = 'quota';
|
|
78
|
+
text = `免费额度耗尽:${msg}`;
|
|
79
|
+
} else if (status === 401 || status === 403) {
|
|
68
80
|
category = 'auth';
|
|
69
81
|
text = `鉴权失败:${msg}`;
|
|
70
82
|
} else if (status === 429) {
|
|
71
|
-
category =
|
|
72
|
-
text =
|
|
83
|
+
category = code === 'BudgetLimitExceeded' ? 'quota' : 'rate_limit';
|
|
84
|
+
text = `${category === 'quota' ? '预算额度耗尽' : '请求限流'}:${msg}`;
|
|
73
85
|
} else if (/InvalidParameter|NotFound\.Model|unsupported/i.test(code)) {
|
|
74
86
|
category = 'invalid_parameter';
|
|
75
87
|
text = `模型或参数不受支持:${msg}`;
|
|
@@ -79,7 +91,8 @@ function classifyHttpError(status, body, fallback) {
|
|
|
79
91
|
} else {
|
|
80
92
|
text = `${fallback}:${msg}`;
|
|
81
93
|
}
|
|
82
|
-
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')) } : {}) });
|
|
83
96
|
}
|
|
84
97
|
|
|
85
98
|
/** 包装 fetch 层的网络异常(无 HTTP 状态) */
|
|
@@ -93,8 +106,8 @@ export function networkError(err) {
|
|
|
93
106
|
return e;
|
|
94
107
|
}
|
|
95
108
|
|
|
96
|
-
function providerError(status, body, fallback) {
|
|
97
|
-
return classifyHttpError(status, body, fallback);
|
|
109
|
+
function providerError(status, body, fallback, headers) {
|
|
110
|
+
return classifyHttpError(status, body, fallback, headers);
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
/* ---------------- DashScope:图像(旧异步 / 新版多模态) ---------------- */
|
|
@@ -126,7 +139,7 @@ export async function generateImageMultimodal({ key, baseUrl, model, prompt, siz
|
|
|
126
139
|
})
|
|
127
140
|
});
|
|
128
141
|
const body = await readJson(res);
|
|
129
|
-
if (!res.ok) throw providerError(res.status, body, '生成失败');
|
|
142
|
+
if (!res.ok) throw providerError(res.status, body, '生成失败', res.headers);
|
|
130
143
|
const urls = [];
|
|
131
144
|
for (const choice of (body && body.output && body.output.choices) || []) {
|
|
132
145
|
for (const item of (choice && choice.message && choice.message.content) || []) {
|
|
@@ -154,7 +167,7 @@ export async function submitImage({ key, baseUrl, model, prompt, size, n = 1, ti
|
|
|
154
167
|
body: JSON.stringify({ model, input: { prompt }, parameters: { ...(size ? { size } : {}), n: Number(n) || 1 } })
|
|
155
168
|
});
|
|
156
169
|
const body = await readJson(res);
|
|
157
|
-
if (!res.ok) throw providerError(res.status, body, '提交失败');
|
|
170
|
+
if (!res.ok) throw providerError(res.status, body, '提交失败', res.headers);
|
|
158
171
|
const taskId = body && body.output && body.output.task_id;
|
|
159
172
|
if (!taskId) throw new Error('提交成功但未返回 task_id');
|
|
160
173
|
return taskId;
|
|
@@ -196,7 +209,7 @@ export async function submitVideo({ key, baseUrl, model, prompt, imgDataUrl, siz
|
|
|
196
209
|
body: JSON.stringify({ model, input, parameters })
|
|
197
210
|
});
|
|
198
211
|
const body = await readJson(res);
|
|
199
|
-
if (!res.ok) throw providerError(res.status, body, '提交失败');
|
|
212
|
+
if (!res.ok) throw providerError(res.status, body, '提交失败', res.headers);
|
|
200
213
|
const taskId = body && body.output && body.output.task_id;
|
|
201
214
|
if (!taskId) throw new Error('提交成功但未返回 task_id');
|
|
202
215
|
return taskId;
|
|
@@ -215,7 +228,7 @@ export async function uploadTempFile({ key, baseUrl, model, filePath, timeoutMs
|
|
|
215
228
|
u.searchParams.set('model', model);
|
|
216
229
|
const pres = await fetch(u, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
|
|
217
230
|
const pbody = await readJson(pres);
|
|
218
|
-
if (!pres.ok || !pbody || !pbody.data) throw providerError(pres.status, pbody, '获取上传凭证失败');
|
|
231
|
+
if (!pres.ok || !pbody || !pbody.data) throw providerError(pres.status, pbody, '获取上传凭证失败', pres.headers);
|
|
219
232
|
const pol = pbody.data;
|
|
220
233
|
const name = path.basename(filePath);
|
|
221
234
|
const keyPath = `${pol.upload_dir}/${Date.now()}-${name}`;
|
|
@@ -243,7 +256,7 @@ export async function pollTask({ key, baseUrl, remoteTaskId, timeoutMs = T_POLL,
|
|
|
243
256
|
signal: timeoutSignal(timeoutMs, signal)
|
|
244
257
|
});
|
|
245
258
|
const body = await readJson(res);
|
|
246
|
-
if (!res.ok) throw providerError(res.status, body, '查询任务失败');
|
|
259
|
+
if (!res.ok) throw providerError(res.status, body, '查询任务失败', res.headers);
|
|
247
260
|
const out = (body && body.output) || {};
|
|
248
261
|
const status = out.task_status || 'UNKNOWN';
|
|
249
262
|
const urls = [];
|
|
@@ -288,7 +301,7 @@ export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', t
|
|
|
288
301
|
body: JSON.stringify({ model, input: { text, voice } })
|
|
289
302
|
});
|
|
290
303
|
const body = await readJson(res);
|
|
291
|
-
if (!res.ok) throw providerError(res.status, body, '合成失败');
|
|
304
|
+
if (!res.ok) throw providerError(res.status, body, '合成失败', res.headers);
|
|
292
305
|
const audio = body && body.output && body.output.audio;
|
|
293
306
|
const url = audio && (audio.url || audio.audio_url);
|
|
294
307
|
const b64 = audio && typeof audio.data === 'string' ? audio.data : null;
|
|
@@ -306,36 +319,171 @@ export async function openAiGenerateImage({ key, baseUrl, model, prompt, size =
|
|
|
306
319
|
body: JSON.stringify({ model, prompt, size, n: Number(n) || 1 })
|
|
307
320
|
});
|
|
308
321
|
const body = await readJson(res);
|
|
309
|
-
if (!res.ok) throw providerError(res.status, body, '生成失败');
|
|
322
|
+
if (!res.ok) throw providerError(res.status, body, '生成失败', res.headers);
|
|
310
323
|
const items = (body && Array.isArray(body.data) && body.data) || [];
|
|
311
324
|
const out = [];
|
|
312
325
|
for (const it of items) {
|
|
313
326
|
if (it && typeof it.b64_json === 'string') out.push({ b64: it.b64_json });
|
|
314
327
|
else if (it && typeof it.url === 'string') out.push({ url: it.url });
|
|
315
328
|
}
|
|
316
|
-
if (!out.length) throw
|
|
329
|
+
if (!out.length) throw imageResponseError('生成响应未返回图片数据');
|
|
317
330
|
return out;
|
|
318
331
|
}
|
|
319
332
|
|
|
320
|
-
|
|
333
|
+
function imageResponseError(message) {
|
|
334
|
+
return new ProviderContractError(message, {
|
|
335
|
+
stage: 'response', category: 'protocol', acceptance: 'unknown'
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function imageBase64Item(value, mediaType, label) {
|
|
340
|
+
const b64 = typeof value === 'string' ? value.replace(/\s/g, '') : '';
|
|
341
|
+
const bytes = Buffer.from(b64, 'base64');
|
|
342
|
+
if (!bytes.length || bytes.toString('base64').replace(/=+$/, '') !== b64.replace(/=+$/, '')) {
|
|
343
|
+
throw imageResponseError(label + '响应包含无效的图片 base64');
|
|
344
|
+
}
|
|
345
|
+
return { b64: bytes.toString('base64'), ...(mediaType ? { mediaType } : {}) };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** 网关扩展:图片 Markdown/data URL、image_url 内容块或 message.images。 */
|
|
349
|
+
function chatImageItems(body, label = '聊天生图') {
|
|
350
|
+
const items = [], seen = new Set();
|
|
351
|
+
function add(source) {
|
|
352
|
+
if (typeof source !== 'string') return;
|
|
353
|
+
const value = source.trim();
|
|
354
|
+
if (seen.has(value)) return;
|
|
355
|
+
const data = /^data:(image\/[a-z0-9.+-]+);base64,([\s\S]+)$/i.exec(value);
|
|
356
|
+
if (data) {
|
|
357
|
+
items.push(imageBase64Item(data[2], data[1].toLowerCase(), label));
|
|
358
|
+
} else if (/^https?:\/\//i.test(value)) {
|
|
359
|
+
items.push({ url: value });
|
|
360
|
+
} else return;
|
|
361
|
+
seen.add(value);
|
|
362
|
+
}
|
|
363
|
+
function text(content) {
|
|
364
|
+
for (const match of content.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) add(match[1]);
|
|
365
|
+
for (const match of content.matchAll(/data:image\/[a-z0-9.+-]+;base64,[^\s)"<>]+/gi)) add(match[0]);
|
|
366
|
+
}
|
|
367
|
+
function part(item) {
|
|
368
|
+
if (item?.type === 'text' && typeof item.text === 'string') text(item.text);
|
|
369
|
+
else if (item?.image_url) add(typeof item.image_url === 'string' ? item.image_url : item.image_url.url);
|
|
370
|
+
}
|
|
371
|
+
for (const choice of Array.isArray(body?.choices) ? body.choices : []) {
|
|
372
|
+
const message = choice?.message;
|
|
373
|
+
if (typeof message?.content === 'string') text(message.content);
|
|
374
|
+
else if (Array.isArray(message?.content)) message.content.forEach(part);
|
|
375
|
+
if (Array.isArray(message?.images)) message.images.forEach(part);
|
|
376
|
+
}
|
|
377
|
+
if (!items.length) throw imageResponseError(label + '响应未返回图片;受理结果未知,禁止自动重提');
|
|
378
|
+
return items;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function openAiChatGenerateImage({ key, baseUrl, model, prompt, image, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
|
|
382
|
+
if (typeof size === 'string' && size.trim()) {
|
|
383
|
+
throw new ProviderContractError('聊天生图协议不支持 size 参数;请在提示词中描述所需尺寸', {
|
|
384
|
+
stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
const base = String(baseUrl || '').replace(/\/+$/, '');
|
|
388
|
+
const content = image ? [
|
|
389
|
+
{ type: 'text', text: prompt },
|
|
390
|
+
{ type: 'image_url', image_url: { url: 'data:' + image.mediaType + ';base64,' + Buffer.from(image.bytes).toString('base64') } }
|
|
391
|
+
] : prompt;
|
|
392
|
+
const res = await fetch(`${base}/chat/completions`, {
|
|
393
|
+
method: 'POST', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
|
|
394
|
+
body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content }],
|
|
395
|
+
...(Number(n) > 1 ? { n: Number(n) } : {}) })
|
|
396
|
+
});
|
|
397
|
+
const body = await readJson(res);
|
|
398
|
+
if (!res.ok) throw providerError(res.status, body, '聊天生图失败', res.headers);
|
|
399
|
+
return chatImageItems(body);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** 同步 Responses 生图:主模型由 model 指定,图片工具模型沿用服务端默认。 */
|
|
403
|
+
export async function openAiResponsesGenerateImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
|
|
404
|
+
if (Number(n) !== 1) {
|
|
405
|
+
throw new ProviderContractError('Responses 生图当前只支持 n=1', {
|
|
406
|
+
stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
const base = String(baseUrl || '').replace(/\/+$/, '');
|
|
410
|
+
const res = await fetch(`${base}/responses`, {
|
|
411
|
+
method: 'POST', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
|
|
412
|
+
body: JSON.stringify({ model, input: prompt, stream: false, store: false, background: false,
|
|
413
|
+
tools: [{ type: 'image_generation', action: 'generate', ...(size ? { size: size.replace('*', 'x') } : {}) }],
|
|
414
|
+
tool_choice: { type: 'image_generation' } })
|
|
415
|
+
});
|
|
416
|
+
const body = await readJson(res);
|
|
417
|
+
if (!res.ok) throw providerError(res.status, body, 'Responses 生图失败', res.headers);
|
|
418
|
+
if (body?.status !== 'completed') throw imageResponseError('Responses 生图未返回同步完成结果;禁止自动重提');
|
|
419
|
+
const output = Array.isArray(body.output) ? body.output : [];
|
|
420
|
+
const images = output.filter(item => item?.type === 'image_generation_call');
|
|
421
|
+
if (images.length) return images.map(item => {
|
|
422
|
+
if (item.status !== 'completed') throw imageResponseError('Responses 图片工具未完成;禁止自动重提');
|
|
423
|
+
return imageBase64Item(item.result, undefined, 'Responses 生图');
|
|
424
|
+
});
|
|
425
|
+
// 实测网关将图片作为 output_text 返回;这不证明执行了官方图片工具。
|
|
426
|
+
const messages = output.filter(item => item?.type === 'message');
|
|
427
|
+
if (messages.some(item => item.status !== 'completed')) throw imageResponseError('Responses 图片消息未完成;禁止自动重提');
|
|
428
|
+
return chatImageItems({ choices: messages.map(item => ({ message: {
|
|
429
|
+
content: (Array.isArray(item.content) ? item.content : []).map(part => part?.type === 'output_text'
|
|
430
|
+
? { type: 'text', text: part.text } : part)
|
|
431
|
+
} })) }, 'Responses 生图');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/* ---------------- 模型发现(OpenAI GET /models + DashScope GET /api/v1/models) ---------------- */
|
|
321
435
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
* @returns {Promise<string[]
|
|
436
|
+
* 拉取供应商模型目录。DashScope 官方端点分页读取并保留 capabilities;
|
|
437
|
+
* 普通 OpenAI 兼容端点维持 GET /models 与字符串 id 返回形状。
|
|
438
|
+
* @returns {Promise<Array<string|{id:string,capabilities?:string[]}>>}
|
|
325
439
|
*/
|
|
326
440
|
export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal }) {
|
|
327
441
|
const base = String(baseUrl || '').trim().replace(/\/+$/, '');
|
|
328
442
|
if (!base) throw new Error('iris: 供应商缺 baseUrl,无法发现模型');
|
|
329
|
-
const
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
for (
|
|
335
|
-
const
|
|
336
|
-
if (
|
|
443
|
+
const dashscope = isDashScopeBaseUrl(base);
|
|
444
|
+
const endpoint = dashscope ? dashscopeApiBase(base) + '/models' : base + '/models';
|
|
445
|
+
const found = new Map();
|
|
446
|
+
const maxPages = dashscope ? 50 : 1;
|
|
447
|
+
let loaded = 0;
|
|
448
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
449
|
+
const url = new URL(endpoint);
|
|
450
|
+
if (dashscope) {
|
|
451
|
+
url.searchParams.set('page_no', String(page));
|
|
452
|
+
url.searchParams.set('page_size', '100');
|
|
453
|
+
}
|
|
454
|
+
const res = await fetch(url, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
|
|
455
|
+
const body = await readJson(res);
|
|
456
|
+
if (!res.ok) throw providerError(res.status, body, '模型发现失败', res.headers);
|
|
457
|
+
const output = body && body.output;
|
|
458
|
+
const items = (output && Array.isArray(output.models) && output.models)
|
|
459
|
+
|| (body && Array.isArray(body.data) && body.data)
|
|
460
|
+
|| (Array.isArray(body && body.models) ? body.models : []);
|
|
461
|
+
loaded += items.length;
|
|
462
|
+
for (const item of items) {
|
|
463
|
+
const id = String(typeof item === 'string' ? item : item?.id || item?.model || '').trim();
|
|
464
|
+
if (!id || found.has(id)) continue;
|
|
465
|
+
const capabilities = Array.isArray(item?.capabilities)
|
|
466
|
+
? item.capabilities.filter((value) => typeof value === 'string' && value)
|
|
467
|
+
: [];
|
|
468
|
+
const requestModalities = Array.isArray(item?.inference_metadata?.request_modality)
|
|
469
|
+
? item.inference_metadata.request_modality.map((value) => String(value).toLowerCase()) : [];
|
|
470
|
+
const responseModalities = Array.isArray(item?.inference_metadata?.response_modality)
|
|
471
|
+
? item.inference_metadata.response_modality.map((value) => String(value).toLowerCase()) : [];
|
|
472
|
+
if (responseModalities.includes('image')) capabilities.push('IG');
|
|
473
|
+
if (responseModalities.includes('video')) capabilities.push('VG');
|
|
474
|
+
if (requestModalities.includes('audio') && responseModalities.includes('text')) capabilities.push('ASR');
|
|
475
|
+
if (requestModalities.includes('text') && responseModalities.includes('audio')) capabilities.push('TTS');
|
|
476
|
+
if (requestModalities.includes('image') && responseModalities.includes('text')) capabilities.push('VU');
|
|
477
|
+
const normalizedCapabilities = [...new Set(capabilities)];
|
|
478
|
+
found.set(id, normalizedCapabilities.length ? { id, capabilities: normalizedCapabilities } : id);
|
|
479
|
+
}
|
|
480
|
+
if (!dashscope) break;
|
|
481
|
+
const statedTotal = Number(output?.total);
|
|
482
|
+
const total = Number.isFinite(statedTotal) && statedTotal >= 0 ? statedTotal : null;
|
|
483
|
+
if (!items.length || (total !== null && loaded >= total) || items.length < 100) break;
|
|
484
|
+
if (page === maxPages) throw new Error('iris: 模型目录超过安全分页上限,已保留原模型池');
|
|
337
485
|
}
|
|
338
|
-
return
|
|
486
|
+
return [...found.values()];
|
|
339
487
|
}
|
|
340
488
|
|
|
341
489
|
/* ---------------- 视觉理解(OpenAI 兼容 SSE 流式) ---------------- */
|
|
@@ -374,7 +522,7 @@ export async function visionStream({ key, baseUrl, model = 'qwen-vl-plus', promp
|
|
|
374
522
|
const text = await res.text().catch(() => '');
|
|
375
523
|
let body = null;
|
|
376
524
|
try { body = JSON.parse(text); } catch (_) { /* keep null */ }
|
|
377
|
-
throw providerError(res.status, body, '视觉理解失败');
|
|
525
|
+
throw providerError(res.status, body, '视觉理解失败', res.headers);
|
|
378
526
|
}
|
|
379
527
|
let full = '';
|
|
380
528
|
const decoder = new TextDecoder();
|
|
@@ -451,7 +599,7 @@ export async function submitTranscription({ key, baseUrl, model = 'qwen-audio-3.
|
|
|
451
599
|
body: JSON.stringify({ model, input })
|
|
452
600
|
});
|
|
453
601
|
const body = await readJson(res);
|
|
454
|
-
if (!res.ok) throw providerError(res.status, body, '提交转写失败');
|
|
602
|
+
if (!res.ok) throw providerError(res.status, body, '提交转写失败', res.headers);
|
|
455
603
|
const taskId = body && body.output && body.output.task_id;
|
|
456
604
|
if (!taskId) throw new Error('提交成功但未返回 task_id');
|
|
457
605
|
return taskId;
|
|
@@ -483,7 +631,7 @@ export async function pollTranscriptionTask({ key, baseUrl, remoteTaskId, timeou
|
|
|
483
631
|
signal: timeoutSignal(timeoutMs, signal)
|
|
484
632
|
});
|
|
485
633
|
const body = await readJson(res);
|
|
486
|
-
if (!res.ok) throw providerError(res.status, body, '查询转写任务失败');
|
|
634
|
+
if (!res.ok) throw providerError(res.status, body, '查询转写任务失败', res.headers);
|
|
487
635
|
const out = (body && body.output) || {};
|
|
488
636
|
const status = out.task_status || 'UNKNOWN';
|
|
489
637
|
let text = '';
|