@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.
Files changed (110) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +10 -5
  2. package/.dsh/skills/iris-compose-media/references/workflows.md +7 -1
  3. package/.dsh/skills/iris-verify-ui/SKILL.md +2 -1
  4. package/CHANGELOG.md +99 -1
  5. package/README.en.md +124 -31
  6. package/README.md +124 -31
  7. package/bin/dsh-iris.js +593 -13
  8. package/docs/AGENT_QUICKSTART.md +39 -0
  9. package/docs/ARCHITECTURE.md +87 -64
  10. package/docs/ARTIFACT_LIBRARY.md +5 -5
  11. package/docs/ARTIFACT_MANIFEST.md +60 -0
  12. package/docs/CLI_MANAGEMENT.md +181 -0
  13. package/docs/COMPOSITE_VISION.md +35 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +84 -0
  15. package/docs/DSH_CORE_MIGRATION.md +135 -0
  16. package/docs/DSH_RC2_ADAPTATION.md +38 -0
  17. package/docs/FAULT_INJECTION.md +12 -2
  18. package/docs/HEADLESS_CLI.md +223 -0
  19. package/docs/HOST_ADAPTER_CONTRACT.md +36 -17
  20. package/docs/HOST_DOCTOR.md +4 -2
  21. package/docs/MODEL_PORT_CONTRACT.md +305 -0
  22. package/docs/OCR_MODEL.md +39 -0
  23. package/docs/PROMPT_OPTIMIZER.md +47 -0
  24. package/docs/PROVIDER_ADAPTER_CONTRACT.md +53 -10
  25. package/docs/PROVIDER_HEALTH.md +22 -5
  26. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  27. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  28. package/docs/ROADMAP.md +49 -83
  29. package/docs/SECURITY.md +8 -7
  30. package/docs/TASK_SEMANTICS.md +2 -2
  31. package/docs/VISION_CLI.md +132 -0
  32. package/docs/VISION_MODEL.md +51 -0
  33. package/docs/WORKBENCH_ARTIFACTS.md +67 -0
  34. package/docs/assets/logo/README.md +55 -0
  35. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  36. package/docs/assets/logo/iris-bubble.svg +14 -0
  37. package/docs/assets/logo/iris-interim-flower.png +0 -0
  38. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  39. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  40. package/docs/assets/logo/iris-logo.svg +78 -0
  41. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  42. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  43. package/docs/assets/logo/iris-mark.svg +42 -0
  44. package/docs/file-access-across-environments.md +8 -5
  45. package/docs/releases/0.2.0.md +48 -0
  46. package/docs/releases/0.2.1.md +56 -0
  47. package/lib/actions.js +520 -591
  48. package/lib/adapters.js +184 -36
  49. package/lib/api.js +200 -11
  50. package/lib/chromium-browser.js +117 -0
  51. package/lib/client.js +1390 -105
  52. package/lib/command-service.js +534 -0
  53. package/lib/composite-vision-routing.js +42 -0
  54. package/lib/config.js +143 -51
  55. package/lib/core-artifact-store.js +630 -0
  56. package/lib/core-artifacts.js +4 -0
  57. package/lib/core-attention.js +103 -0
  58. package/lib/core-contract.js +105 -0
  59. package/lib/core-doctor.js +41 -0
  60. package/lib/core-lease-recovery.js +170 -0
  61. package/lib/core-maintenance.js +213 -0
  62. package/lib/core-runtime.js +232 -0
  63. package/lib/core-tasks.js +567 -0
  64. package/lib/core-user-projection.js +193 -0
  65. package/lib/doctor.js +92 -33
  66. package/lib/dsh-core-adapter.js +653 -0
  67. package/lib/dsh-host-adapter.js +58 -7
  68. package/lib/dsh-text-model-adapter.js +222 -0
  69. package/lib/dsh-vision-model-adapter.js +75 -0
  70. package/lib/generation-input.js +126 -0
  71. package/lib/guard.js +3 -3
  72. package/lib/headless-vision.js +181 -0
  73. package/lib/host-runtime.js +1 -0
  74. package/lib/http-vision-model-adapter.js +187 -0
  75. package/lib/image-edit-input.js +34 -0
  76. package/lib/index.js +323 -188
  77. package/lib/locate.js +86 -28
  78. package/lib/media-probe.js +2 -1
  79. package/lib/model-call-runtime.js +45 -0
  80. package/lib/model-invoker.js +220 -0
  81. package/lib/model-port-contract.js +341 -0
  82. package/lib/models.js +105 -14
  83. package/lib/ocr-model-routing.js +26 -0
  84. package/lib/ocr.js +110 -64
  85. package/lib/prompt-optimizer-config.js +2 -1
  86. package/lib/prompt-optimizer-core.js +121 -0
  87. package/lib/prompt-optimizer.js +37 -193
  88. package/lib/provider-adapter.js +49 -23
  89. package/lib/provider-adapters.js +165 -26
  90. package/lib/provider-catalog.js +183 -0
  91. package/lib/provider-config-service.js +236 -0
  92. package/lib/provider-contract.js +74 -4
  93. package/lib/provider-health.js +80 -1
  94. package/lib/provider-protocol.js +74 -2
  95. package/lib/provider-task-runner.js +370 -0
  96. package/lib/summarize.js +31 -20
  97. package/lib/summary-input.js +52 -0
  98. package/lib/task-wait.js +40 -0
  99. package/lib/tasks.js +16 -4
  100. package/lib/video-input.js +30 -0
  101. package/lib/vision-core.js +42 -0
  102. package/lib/vision-image-input.js +83 -0
  103. package/lib/vision-input.js +31 -0
  104. package/lib/vision-model-routing.js +180 -0
  105. package/lib/vision.js +31 -9
  106. package/lib/workbench-artifacts.js +52 -0
  107. package/lib/workbench-management.js +151 -0
  108. package/lib/workbench-zip.js +39 -0
  109. package/package.json +8 -8
  110. 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 { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' };
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 = (body && (body.message || (body.output && body.output.message))) || (body && body.code) || `HTTP ${status}`;
64
- const code = String((body && body.code) || '');
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 === 401 || status === 403) {
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 = /quota|insufficient|balance|limit|额度/i.test(msg + ' ' + code) ? 'quota' : 'rate_limit';
72
- text = `限流或额度不足:${msg}`;
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 new Error('生成完成但未返回图片数据');
329
+ if (!out.length) throw imageResponseError('生成响应未返回图片数据');
317
330
  return out;
318
331
  }
319
332
 
320
- /* ---------------- 模型发现(OpenAI 兼容 GET /models,各家通用) ---------------- */
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
- * 拉取 provider 可用模型 id 列表。OpenAI 标准端点,DashScope compatible-mode 亦支持。
323
- * 免费只读;返回 [{ id }] 原样(分类由 models.js 规则负责)。
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 res = await fetch(`${base}/models`, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
330
- const body = await readJson(res);
331
- if (!res.ok) throw providerError(res.status, body, '模型发现失败');
332
- const items = (body && Array.isArray(body.data) && body.data) || (Array.isArray(body && body.models) ? body.models : []);
333
- const ids = [];
334
- for (const it of items) {
335
- const id = typeof it === 'string' ? it : it && it.id;
336
- if (typeof id === 'string' && id && !ids.includes(id)) ids.push(id);
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 ids;
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 = '';