@mokuyoaxis/dsh-iris 0.2.1 → 0.2.3

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 (76) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +23 -73
  2. package/.dsh/skills/iris-compose-media/agents/openai.yaml +1 -1
  3. package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +23 -22
  4. package/.dsh/skills/iris-compose-media/references/workflows.md +25 -30
  5. package/.dsh/skills/iris-use/SKILL.md +41 -0
  6. package/.dsh/skills/iris-use/agents/openai.yaml +4 -0
  7. package/.dsh/skills/iris-use/references/media-contract.md +73 -0
  8. package/.dsh/skills/iris-verify-ui/SKILL.md +9 -7
  9. package/.dsh/skills/iris-verify-ui/agents/openai.yaml +1 -1
  10. package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +3 -0
  11. package/CHANGELOG.md +48 -2
  12. package/README.en.md +47 -40
  13. package/README.md +45 -39
  14. package/bin/dsh-iris.js +27 -6
  15. package/docs/AGENT_QUICKSTART.md +9 -9
  16. package/docs/ARCHITECTURE.md +11 -3
  17. package/docs/ARTIFACT_LIBRARY.md +1 -1
  18. package/docs/CLI_MANAGEMENT.md +5 -3
  19. package/docs/COMPOSITE_VISION.md +1 -1
  20. package/docs/CORE_RUNTIME_CONTRACT.md +12 -2
  21. package/docs/DSH_CORE_MIGRATION.md +7 -3
  22. package/docs/FAULT_INJECTION.md +11 -1
  23. package/docs/HEADLESS_CLI.md +10 -4
  24. package/docs/HOST_ADAPTER_CONTRACT.md +1 -1
  25. package/docs/HOST_DOCTOR.md +3 -3
  26. package/docs/MODEL_PORT_CONTRACT.md +9 -3
  27. package/docs/MUSIC.md +77 -0
  28. package/docs/OCR_MODEL.md +1 -1
  29. package/docs/PROMPT_OPTIMIZER.md +74 -6
  30. package/docs/PROVIDER_ADAPTER_CONTRACT.md +12 -2
  31. package/docs/PROVIDER_HEALTH.md +1 -1
  32. package/docs/ROADMAP.md +15 -18
  33. package/docs/SECURITY.md +4 -3
  34. package/docs/SYNC_DELIVERY_RECOVERY.md +39 -0
  35. package/docs/TASK_SEMANTICS.md +5 -5
  36. package/docs/VISION_CLI.md +1 -1
  37. package/docs/VISION_MODEL.md +1 -1
  38. package/docs/WORKBENCH_ARTIFACTS.md +1 -1
  39. package/docs/releases/0.2.2.md +31 -0
  40. package/docs/releases/0.2.3.md +54 -0
  41. package/lib/actions.js +52 -19
  42. package/lib/adapters.js +89 -24
  43. package/lib/api.js +14 -5
  44. package/lib/bundled-skills.js +1 -1
  45. package/lib/capability.js +25 -16
  46. package/lib/client.js +374 -43
  47. package/lib/command-service.js +12 -3
  48. package/lib/config.js +39 -6
  49. package/lib/core-artifact-store.js +44 -6
  50. package/lib/core-delivery-cache.js +145 -0
  51. package/lib/core-lease-recovery.js +64 -92
  52. package/lib/core-maintenance.js +12 -1
  53. package/lib/core-runtime.js +45 -47
  54. package/lib/core-tasks.js +19 -5
  55. package/lib/core-user-projection.js +2 -2
  56. package/lib/core-writer-lease.js +134 -0
  57. package/lib/dsh-core-adapter.js +14 -4
  58. package/lib/dsh-text-model-adapter.js +15 -2
  59. package/lib/generation-input.js +10 -0
  60. package/lib/host-runtime.js +4 -2
  61. package/lib/index.js +23 -4
  62. package/lib/models.js +23 -38
  63. package/lib/music-generation.js +21 -0
  64. package/lib/music-options.js +18 -0
  65. package/lib/music-transport.js +46 -0
  66. package/lib/prompt-optimizer-config.js +111 -30
  67. package/lib/prompt-optimizer-core.js +183 -12
  68. package/lib/prompt-optimizer.js +20 -14
  69. package/lib/provider-adapter.js +1 -0
  70. package/lib/provider-adapters.js +26 -2
  71. package/lib/provider-catalog.js +18 -7
  72. package/lib/provider-config-service.js +16 -5
  73. package/lib/provider-protocol.js +73 -0
  74. package/lib/provider-task-runner.js +86 -9
  75. package/package.json +4 -2
  76. package/user_guide.md +52 -24
package/lib/actions.js CHANGED
@@ -23,7 +23,7 @@ import { registerMedia, mediaLinksOf, webBase } from './media.js';
23
23
  import { RED_TEST_IMAGE } from './vision.js';
24
24
  import { runVisionRequest, readVisionImageFile, probeVisionModel, visionImageFromDataUrl } from './vision-model-routing.js';
25
25
  import { HOST_CONTRACT_VERSION, HOST_PORTS, defineHostAdapter, hasHostPort, requireHostPort } from './host-contract.js';
26
- import { coreSnapshotForDsh, cropForDsh, diffForDsh, framesForDsh, summaryFramesForDsh, visionImageForDsh, htmlForDsh, inspectProviderTaskForDsh, submitProviderTaskForDsh, watchProviderTaskForDsh } from './dsh-core-adapter.js';
26
+ import { coreSnapshotForDsh, cropForDsh, diffForDsh, framesForDsh, summaryFramesForDsh, visionImageForDsh, htmlForDsh, inspectProviderTaskForDsh, submitMusicForDsh, submitProviderTaskForDsh, watchProviderTaskForDsh } from './dsh-core-adapter.js';
27
27
  import { runLocateRequest, runSummaryRequest } from './composite-vision-routing.js';
28
28
  import { normalizeSummarySource, summaryFrameRecords, summaryTimelineLabel } from './summary-input.js';
29
29
  import { normalizeVisionImageSource } from './vision-input.js';
@@ -32,7 +32,7 @@ import { runOcrRequest } from './ocr-model-routing.js';
32
32
  import { ffmpegAvailable, extractFrames, probeVideo } from './media-probe.js';
33
33
  import { ensurePrivateDir, writePrivateFile } from './private-storage.js';
34
34
  import { selectMediaProtocol, selectImageProtocol, providerForImageModel } from './provider-protocol.js';
35
- import { providerTaskBinding } from './provider-catalog.js';
35
+ import { musicCandidatesFromCatalog, providerTaskBinding } from './provider-catalog.js';
36
36
  import { invokeProviderOperation, providerPollTaskView, prepareProviderInput } from './provider-adapter.js';
37
37
  import { createConfiguredProviderAdapter as createProviderAdapter } from './provider-adapters.js';
38
38
  import { deriveUserState } from './task-semantics.js';
@@ -136,7 +136,8 @@ function imageRoute(provider, args) {
136
136
  }
137
137
 
138
138
  function taskActionError(taskId, message, details) {
139
- const error = new Error('iris: ' + message + '(task: ' + taskId + ')');
139
+ const diagnostic = details?.safeMessage ? ':' + details.safeMessage : '';
140
+ const error = new Error('iris: ' + message + '(task: ' + taskId + ')' + diagnostic);
140
141
  error.taskId = taskId;
141
142
  if (details) error.details = details;
142
143
  return error;
@@ -563,6 +564,33 @@ async function submitCoreTts(routes, normalizedInput, signal) {
563
564
  }
564
565
 
565
566
  /* ---------- 🔊 语音合成 ---------- */
567
+ register('music', async (host, args, { signal }) => {
568
+ const normalized = normalizeGenerationInput('music', {
569
+ prompt: args.prompt,
570
+ ...(optionalActionText(args.timeout_ms) !== undefined ? { timeout_ms: args.timeout_ms } : {}),
571
+ ...(optionalActionText(args.retry_502) !== undefined ? { retry_502: args.retry_502 } : {}),
572
+ ...(optionalActionText(args.retry_delay_ms) !== undefined ? { retry_delay_ms: args.retry_delay_ms } : {})
573
+ });
574
+ const routes = musicCandidatesFromCatalog({ providers: store.allProviders(), assignments: store.assignments() }, args.model);
575
+ const submitted = await submitMusicForDsh({
576
+ candidates: routes.map(route => ({ adapter: createConfiguredProviderAdapter(route.provider), model: route.modelRef,
577
+ selectionReason: route.selectionReason, providerBinding: providerTaskBinding(route.provider) })),
578
+ providerInput: normalized.providerInput, musicOptions: normalized.musicOptions, signal
579
+ });
580
+ const task = submitted.task;
581
+ recordCoreTaskAttemptHealth(task, cap.CAPABILITIES.MUSIC);
582
+ if (submitted.localError) throw taskActionError(task.id, 'Music 结果未能可靠落盘', submitted.localError);
583
+ if (task.outcome !== 'succeeded' || task.deliveryState !== 'ready') {
584
+ throw taskActionError(task.id, task.outcome === 'succeeded'
585
+ ? '音乐已返回,但交付失败;可从缓存重新交付'
586
+ : `音乐未取得产物(已重试${submitted.retries}次)`, task.lastError);
587
+ }
588
+ return { ok: true, storage: 'core', taskId: task.id, taskIds: submitted.taskIds, retries: submitted.retries,
589
+ model: task.modelRef, providerId: task.providerId, artifactIds: task.artifactIds,
590
+ audioUrl: '/iris/api/core/artifact/' + encodeURIComponent(task.artifactIds[0]) + '/media',
591
+ text: `[iris] 音乐已生成(${task.modelRef},重试${submitted.retries}次,Core task: ${task.id})` };
592
+ });
593
+
566
594
  register('tts', async (host, args, { signal }) => {
567
595
  const { providerInput } = normalizeGenerationInput('tts', {
568
596
  text: args.text, voice: optionalActionText(args.voice)
@@ -1007,6 +1035,7 @@ register('providers_list', async (host, args) => {
1007
1035
  id: m.id,
1008
1036
  capabilities: m.capabilities,
1009
1037
  ...(m.imageProtocol !== undefined ? { imageProtocol: m.imageProtocol } : {}),
1038
+ ...(m.musicProtocol !== undefined ? { musicProtocol: m.musicProtocol } : {}),
1010
1039
  ...(m.visionInput !== undefined ? { visionInput: m.visionInput } : {}),
1011
1040
  ...(m.capabilities.includes(cap.CAPABILITIES.VISION) ? { visionInputEffective: visionInputLimits(p, m.id) } : {}),
1012
1041
  ...(m.capabilities.includes(cap.CAPABILITIES.IMAGE) ? { imageRouting: selectImageProtocol(p, m.id) } : {}),
@@ -1049,7 +1078,7 @@ register('providers_set_models', async (host, args) => {
1049
1078
  });
1050
1079
 
1051
1080
  /**
1052
- * 模型发现:读取供应商目录 → 官方能力元数据优先、名称规则兜底 → 写入显式池。
1081
+ * 模型发现:读取供应商目录 → 明确能力元数据 → 合并模型池。
1053
1082
  * 免费只读;拉不到/不支持则原样保留现有池(优雅降级),报人话错误。
1054
1083
  */
1055
1084
  register('providers_discover', async (host, args, { signal }) => {
@@ -1063,16 +1092,11 @@ register('providers_discover', async (host, args, { signal }) => {
1063
1092
  } catch (err) {
1064
1093
  throw new Error('iris: 模型发现失败(保留现有池):' + String(err.message || err));
1065
1094
  }
1066
- const media = [];
1067
- for (const model of discoveredModels) {
1068
- const caps = models.capabilitiesOfDiscoveredModel(model);
1069
- if (caps.length) media.push({ id: model.id, capabilities: caps });
1070
- }
1071
- if (!media.length) {
1072
- return { ok: true, total: discoveredModels.length, media: 0, text: `[iris] ${p.name}:拉到 ${discoveredModels.length} 个模型,无媒体能力匹配——可在模型池区手动添加并标能力` };
1073
- }
1074
- store.mergeDiscoveredModels(p.id, media);
1075
- return { ok: true, total: discoveredModels.length, media: media.length, text: `[iris] ${p.name}:发现 ${discoveredModels.length} 个模型,${media.length} 个媒体能力模型已入池(官方元数据优先)` };
1095
+ const entries = discoveredModels.map((model) => ({ id: model.id, capabilities: models.capabilitiesOfDiscoveredModel(model) }));
1096
+ const mediaCount = entries.filter((model) => model.capabilities.length).length;
1097
+ store.mergeDiscoveredModels(p.id, entries);
1098
+ return { ok: true, total: discoveredModels.length, media: mediaCount,
1099
+ text: `[iris] ${p.name}:发现 ${entries.length} 个模型,${mediaCount} 个有能力声明;未标注模型请在模型池中设置能力` };
1076
1100
  });
1077
1101
 
1078
1102
  /** 能力实测(红色测试图):对某供应商的 vision 模型跑 testVisionCapability */
@@ -1104,6 +1128,9 @@ register('providers_test_vision', async (host, args, { signal }) => {
1104
1128
 
1105
1129
  /** 按能力实测单个模型;返回 {ok, note}。真实调用仅在 UI 明确确认后执行。 */
1106
1130
  async function probeModel(host, p, modelId, capability, signal) {
1131
+ if (capability === cap.CAPABILITIES.MUSIC) {
1132
+ return { skipped: true, ok: false, note: 'Music 不发起小样本探测;请通过音乐功能提交实际创作要求' };
1133
+ }
1107
1134
  if (capability === cap.CAPABILITIES.VISION) {
1108
1135
  const r = await probeVisionModel(p, modelId, visionImageFromDataUrl(RED_TEST_IMAGE), { timeoutMs: 15000, signal });
1109
1136
  return {
@@ -1177,17 +1204,17 @@ async function probeModel(host, p, modelId, capability, signal) {
1177
1204
  return { ok: false, note: '未知能力' };
1178
1205
  }
1179
1206
 
1180
- /** 手动添加模型到池(能力可选,缺省按规则推断) */
1207
+ /** 手动添加模型到池;省略或清空能力时保留未标注模型。 */
1181
1208
  register('providers_add_model', async (host, args) => {
1182
1209
  const id = String(args.id || '');
1183
1210
  const modelId = String(args.model_id || '').trim();
1184
1211
  if (!modelId) throw new Error('iris: model_id 不能为空');
1185
- const caps = Array.isArray(args.capabilities) && args.capabilities.length
1212
+ const caps = Array.isArray(args.capabilities)
1186
1213
  ? args.capabilities.filter((c) => cap.isKnownCapability(c))
1187
- : models.capabilitiesOfModel(modelId);
1214
+ : undefined;
1188
1215
  const entry = store.addProviderModel(id, modelId, caps);
1189
1216
  if (!entry) throw new Error('iris: 供应商不存在');
1190
- return { ok: true, text: `[iris] 已添加模型 ${modelId}(能力:${caps.join(',') || '未标注'})` };
1217
+ return { ok: true, text: `[iris] 已添加模型 ${modelId}(能力:${(caps || []).join(',') || '未标注'})` };
1191
1218
  });
1192
1219
 
1193
1220
  /** 从池移除模型 */
@@ -1211,6 +1238,12 @@ register('providers_set_model_image_protocol', async (host, args) => {
1211
1238
  return { ok: true, text: '[iris] 模型图片协议已更新' };
1212
1239
  });
1213
1240
 
1241
+ register('providers_set_model_music_protocol', async (host, args) => {
1242
+ const ok = store.setModelMusicProtocol(String(args.id || ''), String(args.model_id || ''), args.musicProtocol);
1243
+ if (!ok) throw new Error('iris: 模型不在池中');
1244
+ return { ok: true, text: '[iris] 模型音乐协议已更新' };
1245
+ });
1246
+
1214
1247
  register('providers_set_model_vision_input', async (host, args) => {
1215
1248
  if (!Object.hasOwn(args, 'visionInput')) throw new Error('iris: 需要 visionInput 对象或 null');
1216
1249
  const ok = store.setModelVisionInput(String(args.id || ''), String(args.model_id || ''), args.visionInput);
@@ -1225,7 +1258,7 @@ register('providers_test_model', async (host, args, { signal }) => {
1225
1258
  const modelId = String(args.model_id || '').trim();
1226
1259
  const capability = String(args.capability || '').trim();
1227
1260
  if (!modelId || !cap.isKnownCapability(capability)) throw new Error('iris: 需 model_id 与合法 capability');
1228
- if (![cap.CAPABILITIES.VIDEO, cap.CAPABILITIES.TRANSCRIBE].includes(capability) && args.confirm_paid !== true) {
1261
+ if (![cap.CAPABILITIES.VIDEO, cap.CAPABILITIES.TRANSCRIBE, cap.CAPABILITIES.MUSIC].includes(capability) && args.confirm_paid !== true) {
1229
1262
  throw new Error('iris: 模型实测会发起真实供应商请求,请先明确确认');
1230
1263
  }
1231
1264
  let result;
package/lib/adapters.js CHANGED
@@ -11,10 +11,11 @@ import fs from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { Readable } from 'node:stream';
13
13
  import { pipeline } from 'node:stream/promises';
14
- import { dashscopeApiBase, isDashScopeBaseUrl } from './provider-protocol.js';
14
+ import { dashscopeApiBase, imageSizeViolation, isDashScopeBaseUrl, normalizeImageSize } from './provider-protocol.js';
15
15
  import { chmodPrivateFile, ensurePrivateDir, privateSibling } from './private-storage.js';
16
16
  import { isFreeQuotaExhausted, retryAfterDelay } from './provider-health.js';
17
17
  import { ProviderContractError } from './provider-contract.js';
18
+ export { generateMusic } from './music-transport.js';
18
19
 
19
20
  /**
20
21
  * 网络调用超时档位(2026-09-03 健康检查):挂死的连接绝不能冻住盯守 tick——
@@ -95,17 +96,65 @@ function classifyHttpError(status, body, fallback, headers) {
95
96
  ...(status === 429 ? { retryAfterMs: retryAfterDelay(headers?.get('retry-after')) } : {}) });
96
97
  }
97
98
 
98
- /** 包装 fetch 层的网络异常(无 HTTP 状态) */
99
- export function networkError(err) {
99
+ /** 包装 fetch 层的网络异常(无 HTTP 状态)。hint 补一句可执行的排查方向。 */
100
+ export function networkError(err, hint) {
101
+ const code = String(err?.cause?.code || err?.code || '');
100
102
  const e = new ProviderError({
101
103
  category: 'network',
102
104
  status: 0,
103
- message: '网络错误:' + String((err && err.message) || err),
104
- rootCause: err
105
+ message: '网络错误:' + String((err && err.message) || err) + (hint ? '(' + hint + ')' : ''),
106
+ rootCause: err,
107
+ ...(/^[A-Z][A-Z0-9_]{1,63}$/.test(code) ? { providerCode: code } : {})
105
108
  });
106
109
  return e;
107
110
  }
108
111
 
112
+ /** 超时/取消必须保持原分类:轮询 errStreak 容错与 submitGuard 都依赖 network 与 timeout 的区分。 */
113
+ function isAbortLike(err) {
114
+ return err?.name === 'AbortError' || err?.name === 'TimeoutError' || err?.code === 'ABORT_ERR';
115
+ }
116
+
117
+ function transportFetchError(err, response) {
118
+ if (isAbortLike(err)) return err;
119
+ const code = String(err?.cause?.code || err?.code || '');
120
+ const detail = {
121
+ ENOTFOUND: 'DNS 未找到目标主机',
122
+ EAI_AGAIN: 'DNS 解析暂时失败',
123
+ ECONNREFUSED: '目标连接被拒绝',
124
+ UND_ERR_CONNECT_TIMEOUT: '连接建立超时',
125
+ UND_ERR_HEADERS_TIMEOUT: '等待响应头超时',
126
+ UND_ERR_BODY_TIMEOUT: '响应正文读取超时',
127
+ ETIMEDOUT: '网络操作超时',
128
+ ECONNRESET: '连接被重置',
129
+ UND_ERR_SOCKET: '底层连接中断',
130
+ CERT_HAS_EXPIRED: 'TLS 证书已过期',
131
+ DEPTH_ZERO_SELF_SIGNED_CERT: 'TLS 证书不受信任',
132
+ UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'TLS 证书链验证失败'
133
+ }[code];
134
+ const phase = response
135
+ ? `已收到 HTTP ${response.status} 响应头,读取响应正文失败`
136
+ : '请求传输失败,尚未收到响应头';
137
+ const error = networkError(err, phase + (detail ? ';' + detail : '') +
138
+ (code && /^[A-Z][A-Z0-9_]{1,63}$/.test(code) ? ';' + code : '') + ';受理结果未知,禁止自动重提');
139
+ if (['UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'ETIMEDOUT'].includes(code)) {
140
+ error.category = 'timeout';
141
+ }
142
+ error.stage = response ? 'response' : 'submit';
143
+ error.acceptance = 'unknown';
144
+ return error;
145
+ }
146
+
147
+ /** 只包装网络读取;HTTP 业务错误和图片协议校验由调用方处理。 */
148
+ async function fetchImageJson(url, options) {
149
+ let response;
150
+ try {
151
+ response = await fetch(url, options);
152
+ return { res: response, body: await readJson(response) };
153
+ } catch (err) {
154
+ throw transportFetchError(err, response);
155
+ }
156
+ }
157
+
109
158
  function providerError(status, body, fallback, headers) {
110
159
  return classifyHttpError(status, body, fallback, headers);
111
160
  }
@@ -125,20 +174,34 @@ export function dashscopeImageMode(model) {
125
174
  return 'legacy-async';
126
175
  }
127
176
 
177
+ /**
178
+ * 尺寸在进入请求体前统一「归一 + 校验」:
179
+ * 协议差异(分隔符、数值边界)全部来自 provider-protocol.js,本地违规明确标记为未受理。
180
+ * 归一的意义是跨协议可用 —— 例如把 `1024x1536` 送给 DashScope 多模态(服务端要求 `*`),
181
+ * 或把 `1024*1536` 送给 OpenAI 兼容端点(服务端要求 `x`),都不再需要调用方自己记格式。
182
+ */
183
+ export function applyImageSize({ protocol, mode, size }) {
184
+ const violation = imageSizeViolation({ protocol, mode, size });
185
+ if (violation) throw new ProviderContractError(violation, {
186
+ stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
187
+ });
188
+ return normalizeImageSize({ protocol, mode, size });
189
+ }
190
+
128
191
  /** 新版多模态同步协议:响应中直接返回图片 URL。 */
129
192
  export async function generateImageMultimodal({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SYNC_GEN, signal }) {
130
193
  const apiBase = dashscopeApiBase(baseUrl);
131
- const res = await fetch(`${apiBase}/services/aigc/multimodal-generation/generation`, {
194
+ const requestSize = applyImageSize({ protocol: 'dashscope', mode: 'multimodal-sync', size });
195
+ const { res, body } = await fetchImageJson(`${apiBase}/services/aigc/multimodal-generation/generation`, {
132
196
  method: 'POST',
133
197
  headers: authHeaders(key),
134
198
  signal: timeoutSignal(timeoutMs, signal),
135
199
  body: JSON.stringify({
136
200
  model,
137
201
  input: { messages: [{ role: 'user', content: [{ text: prompt }] }] },
138
- parameters: { ...(size ? { size } : {}), n: Number(n) || 1 }
202
+ parameters: { ...(requestSize ? { size: requestSize } : {}), n: Number(n) || 1 }
139
203
  })
140
204
  });
141
- const body = await readJson(res);
142
205
  if (!res.ok) throw providerError(res.status, body, '生成失败', res.headers);
143
206
  const urls = [];
144
207
  for (const choice of (body && body.output && body.output.choices) || []) {
@@ -160,13 +223,13 @@ export async function startImageGeneration(opts) {
160
223
  }
161
224
  export async function submitImage({ key, baseUrl, model, prompt, size, n = 1, timeoutMs = T_SUBMIT, signal }) {
162
225
  const apiBase = dashscopeApiBase(baseUrl);
163
- const res = await fetch(`${apiBase}/services/aigc/text2image/image-synthesis`, {
226
+ const requestSize = applyImageSize({ protocol: 'dashscope', mode: 'legacy-async', size });
227
+ const { res, body } = await fetchImageJson(`${apiBase}/services/aigc/text2image/image-synthesis`, {
164
228
  method: 'POST',
165
229
  headers: { ...authHeaders(key), 'X-DashScope-Async': 'enable' },
166
230
  signal: timeoutSignal(timeoutMs, signal),
167
- body: JSON.stringify({ model, input: { prompt }, parameters: { ...(size ? { size } : {}), n: Number(n) || 1 } })
231
+ body: JSON.stringify({ model, input: { prompt }, parameters: { ...(requestSize ? { size: requestSize } : {}), n: Number(n) || 1 } })
168
232
  });
169
- const body = await readJson(res);
170
233
  if (!res.ok) throw providerError(res.status, body, '提交失败', res.headers);
171
234
  const taskId = body && body.output && body.output.task_id;
172
235
  if (!taskId) throw new Error('提交成功但未返回 task_id');
@@ -312,13 +375,13 @@ export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', t
312
375
  /* ---------------- OpenAI Images 兼容(同步 base64/url) ---------------- */
313
376
  export async function openAiGenerateImage({ key, baseUrl, model, prompt, size = '1024x1024', n = 1, timeoutMs = T_SYNC_GEN, signal }) {
314
377
  const base = String(baseUrl || '').replace(/\/+$/, '');
315
- const res = await fetch(`${base}/images/generations`, {
378
+ const requestSize = applyImageSize({ protocol: 'openai-images', size });
379
+ const { res, body } = await fetchImageJson(`${base}/images/generations`, {
316
380
  method: 'POST',
317
381
  headers: authHeaders(key),
318
382
  signal: timeoutSignal(timeoutMs, signal),
319
- body: JSON.stringify({ model, prompt, size, n: Number(n) || 1 })
383
+ body: JSON.stringify({ model, prompt, size: requestSize, n: Number(n) || 1 })
320
384
  });
321
- const body = await readJson(res);
322
385
  if (!res.ok) throw providerError(res.status, body, '生成失败', res.headers);
323
386
  const items = (body && Array.isArray(body.data) && body.data) || [];
324
387
  const out = [];
@@ -389,12 +452,11 @@ export async function openAiChatGenerateImage({ key, baseUrl, model, prompt, ima
389
452
  { type: 'text', text: prompt },
390
453
  { type: 'image_url', image_url: { url: 'data:' + image.mediaType + ';base64,' + Buffer.from(image.bytes).toString('base64') } }
391
454
  ] : prompt;
392
- const res = await fetch(`${base}/chat/completions`, {
455
+ const { res, body } = await fetchImageJson(`${base}/chat/completions`, {
393
456
  method: 'POST', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
394
457
  body: JSON.stringify({ model, stream: false, messages: [{ role: 'user', content }],
395
458
  ...(Number(n) > 1 ? { n: Number(n) } : {}) })
396
459
  });
397
- const body = await readJson(res);
398
460
  if (!res.ok) throw providerError(res.status, body, '聊天生图失败', res.headers);
399
461
  return chatImageItems(body);
400
462
  }
@@ -407,13 +469,13 @@ export async function openAiResponsesGenerateImage({ key, baseUrl, model, prompt
407
469
  });
408
470
  }
409
471
  const base = String(baseUrl || '').replace(/\/+$/, '');
410
- const res = await fetch(`${base}/responses`, {
472
+ const requestSize = applyImageSize({ protocol: 'openai-responses-images', size });
473
+ const { res, body } = await fetchImageJson(`${base}/responses`, {
411
474
  method: 'POST', headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal),
412
475
  body: JSON.stringify({ model, input: prompt, stream: false, store: false, background: false,
413
- tools: [{ type: 'image_generation', action: 'generate', ...(size ? { size: size.replace('*', 'x') } : {}) }],
476
+ tools: [{ type: 'image_generation', action: 'generate', ...(requestSize ? { size: requestSize } : {}) }],
414
477
  tool_choice: { type: 'image_generation' } })
415
478
  });
416
- const body = await readJson(res);
417
479
  if (!res.ok) throw providerError(res.status, body, 'Responses 生图失败', res.headers);
418
480
  if (body?.status !== 'completed') throw imageResponseError('Responses 生图未返回同步完成结果;禁止自动重提');
419
481
  const output = Array.isArray(body.output) ? body.output : [];
@@ -469,11 +531,14 @@ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal })
469
531
  ? item.inference_metadata.request_modality.map((value) => String(value).toLowerCase()) : [];
470
532
  const responseModalities = Array.isArray(item?.inference_metadata?.response_modality)
471
533
  ? 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');
534
+ // 明确能力代码(含空数组/未知新能力)优先,不能被通用模态推断追加用途。
535
+ if (!Array.isArray(item?.capabilities)) {
536
+ if (responseModalities.includes('image')) capabilities.push('IG');
537
+ if (responseModalities.includes('video')) capabilities.push('VG');
538
+ if (requestModalities.includes('audio') && responseModalities.includes('text')) capabilities.push('ASR');
539
+ if (requestModalities.includes('text') && responseModalities.includes('audio')) capabilities.push('TTS');
540
+ if (requestModalities.includes('image') && responseModalities.includes('text')) capabilities.push('VU');
541
+ }
477
542
  const normalizedCapabilities = [...new Set(capabilities)];
478
543
  found.set(id, normalizedCapabilities.length ? { id, capabilities: normalizedCapabilities } : id);
479
544
  }
package/lib/api.js CHANGED
@@ -33,9 +33,12 @@ import {
33
33
  importPromptOptimizerConfig,
34
34
  loadPromptOptimizerConfig,
35
35
  resetPromptOptimizerConfig,
36
+ resetPromptOptimizerInstructions,
36
37
  setPromptOptimizerEnabled
37
38
  } from './prompt-optimizer-config.js';
38
39
 
40
+ const PROMPT_CONFIG_CAPABILITIES = Object.freeze({ ...PROMPT_OPTIMIZER_CAPABILITIES, resetInstructions: true });
41
+
39
42
  function readJsonSafe(file) {
40
43
  try {
41
44
  const raw = fs.readFileSync(file, 'utf8');
@@ -369,7 +372,7 @@ export function serveApi(req, res, host) {
369
372
  return serveSse(res);
370
373
  }
371
374
  if (urlPath === '/iris/api/prompt-optimizer/config') {
372
- return sendJson(res, 200, method === 'HEAD' ? {} : { ...loadPromptOptimizerConfig(), capabilities: PROMPT_OPTIMIZER_CAPABILITIES });
375
+ return sendJson(res, 200, method === 'HEAD' ? {} : { ...loadPromptOptimizerConfig(), capabilities: PROMPT_CONFIG_CAPABILITIES });
373
376
  }
374
377
  if (urlPath === '/iris/api/doctor') {
375
378
  if (!host || typeof host !== 'object') return sendJson(res, 503, { error: 'host unavailable' });
@@ -506,7 +509,9 @@ function handleJsonPost(req, res, execute, respond = sendJson) {
506
509
  if (overflow) return finish(413, { error: 'request body too large (limit 1MB)' });
507
510
  try {
508
511
  const raw = Buffer.concat(chunks).toString('utf8');
509
- const body = raw ? JSON.parse(raw) : {};
512
+ let body;
513
+ try { body = raw ? JSON.parse(raw) : {}; }
514
+ catch (_) { return finish(controller.signal.aborted ? 499 : 400, { error: controller.signal.aborted ? 'request canceled' : 'invalid JSON' }); }
510
515
  const result = await execute(body, controller.signal);
511
516
  if (controller.signal.aborted) return finish(499, { error: 'request canceled' });
512
517
  finish(200, result);
@@ -630,10 +635,14 @@ function handlePromptOptimizer(host, operation, req, res) {
630
635
  if (operation === 'optimize') return optimizePrompt(host, body, { signal });
631
636
  if (operation === 'import') {
632
637
  const imported = body && body.config !== undefined ? body.config : body;
633
- return { ok: true, ...importPromptOptimizerConfig(imported), capabilities: PROMPT_OPTIMIZER_CAPABILITIES };
638
+ return { ok: true, ...importPromptOptimizerConfig(imported), capabilities: PROMPT_CONFIG_CAPABILITIES };
639
+ }
640
+ if (operation === 'reset') {
641
+ const scope = body?.scope === undefined ? 'all' : body.scope;
642
+ if (!['all', 'instructions'].includes(scope)) throw new Error('scope 只能是 all 或 instructions');
643
+ return { ok: true, ...(scope === 'instructions' ? resetPromptOptimizerInstructions() : resetPromptOptimizerConfig()), capabilities: PROMPT_CONFIG_CAPABILITIES };
634
644
  }
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 };
645
+ if (operation === 'enabled') return { ok: true, ...setPromptOptimizerEnabled(body && body.enabled), capabilities: PROMPT_CONFIG_CAPABILITIES };
637
646
  throw new Error('unknown prompt optimizer operation');
638
647
  });
639
648
  }
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { requireHostPort } from './host-contract.js';
7
7
 
8
- const SKILL_NAMES = ['iris-verify-ui', 'iris-compose-media'];
8
+ const SKILL_NAMES = ['iris-use', 'iris-compose-media', 'iris-verify-ui'];
9
9
  const SKILL_ROOT = fileURLToPath(new URL('../.dsh/skills/', import.meta.url));
10
10
 
11
11
  function parseScalar(value, file) {
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
- * - 旧配置迁移:老 provider(无 capabilities 数组)按模型字段推断能力
9
+ * - 能力只来自明确标签或旧模型字段绑定,不按供应商端点赋能
10
10
  *
11
11
  * 本模块是纯函数,不读写磁盘;只吃 provider 记录、吐能力判断。
12
12
  */
@@ -14,6 +14,7 @@ export const CAPABILITIES = Object.freeze({
14
14
  IMAGE: 'image-gen',
15
15
  VIDEO: 'video-gen',
16
16
  TTS: 'tts',
17
+ MUSIC: 'music-gen',
17
18
  TRANSCRIBE: 'transcribe',
18
19
  VISION: 'vision'
19
20
  });
@@ -26,40 +27,48 @@ const INFER_FIELD = {
26
27
  [CAPABILITIES.IMAGE]: 'imageModel',
27
28
  [CAPABILITIES.VIDEO]: 'videoModel',
28
29
  [CAPABILITIES.TTS]: 'ttsModel',
30
+ [CAPABILITIES.MUSIC]: 'musicModel',
29
31
  [CAPABILITIES.TRANSCRIBE]: 'transcribeModel',
30
32
  [CAPABILITIES.VISION]: 'visionModel'
31
33
  };
32
34
 
35
+ /** 模型级明确标签优先;旧能力字段只为同名模型提供明确绑定。 */
36
+ export function configuredModelCapabilities(provider, model) {
37
+ if (Array.isArray(model?.capabilities)) {
38
+ return [...new Set(model.capabilities.filter(isKnownCapability))];
39
+ }
40
+ const id = typeof model === 'string' ? model.trim() : String(model?.id || '').trim();
41
+ if (!id) return [];
42
+ const bound = Object.entries(INFER_FIELD)
43
+ .filter(([, field]) => typeof provider?.[field] === 'string' && provider[field].trim() === id)
44
+ .map(([capability]) => capability);
45
+ return Array.isArray(provider?.capabilities) ? bound.filter((capability) => provider.capabilities.includes(capability)) : bound;
46
+ }
47
+
33
48
  export function isKnownCapability(capability) {
34
49
  return KNOWN.has(capability);
35
50
  }
36
51
 
37
52
  /**
38
53
  * 一个 provider 的能力列表(去重、只留已知能力)。
39
- * 迁移规则(旧配置):
40
- * ① capabilities 显式声明 → 权威(严格使用,不额外推断);
41
- * ② 否则按模型字段推断(imageModel→image-gen 等);
42
- * ③ 仍为空且是 DashScope 端点 → 全部媒体能力
43
- * (百炼单一 API 服务 t2i/t2v/tts/compatible-vision,裸账号常见于迁移期)。
54
+ * 显式模型池优先,包括空池;其次是账号明确标签(含空数组),
55
+ * 最后兼容旧能力字段绑定。账号地址和模型名称都不证明能力。
44
56
  * @param {object} p provider 记录
45
57
  * @returns {string[]}
46
58
  */
47
59
  export function capabilitiesOf(p) {
48
- const declared = Array.isArray(p && p.capabilities) ? p.capabilities : [];
49
- if (declared.some((c) => isKnownCapability(c))) {
50
- return [...new Set(declared)].filter(isKnownCapability);
60
+ if (Array.isArray(p?.models)) {
61
+ return [...new Set(p.models.flatMap((model) => configuredModelCapabilities(p, model)))];
62
+ }
63
+ if (Array.isArray(p?.capabilities)) {
64
+ return [...new Set(p.capabilities.filter(isKnownCapability))];
51
65
  }
52
66
  // 旧配置迁移:按模型字段推断(有该能力模型才认为具备该能力)
53
67
  const inferred = [];
54
68
  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);
69
+ if (typeof p?.[field] === 'string' && p[field].trim()) inferred.push(cap);
61
70
  }
62
- return [];
71
+ return inferred;
63
72
  }
64
73
 
65
74
  /**