@mokuyoaxis/dsh-iris 0.2.5 → 0.2.6

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 (49) hide show
  1. package/.dsh/skills/iris-use/SKILL.md +2 -2
  2. package/.dsh/skills/iris-use/references/media-contract.md +2 -2
  3. package/CHANGELOG.md +27 -1
  4. package/README.en.md +27 -19
  5. package/README.md +28 -18
  6. package/bin/dsh-iris.js +29 -12
  7. package/docs/AGENT_QUICKSTART.md +7 -7
  8. package/docs/CLI_MANAGEMENT.md +5 -1
  9. package/docs/DSH_COMPATIBILITY.md +8 -4
  10. package/docs/DSH_PROVIDER_CONNECTIONS.md +34 -0
  11. package/docs/FUNCTION_MODEL_ASSIGNMENTS.md +2 -0
  12. package/docs/FUNCTION_MODEL_POOLS.md +84 -0
  13. package/docs/HEADLESS_CLI.md +7 -7
  14. package/docs/HOST_ADAPTER_CONTRACT.md +7 -2
  15. package/docs/HOST_DOCTOR.md +2 -2
  16. package/docs/MUSIC.md +2 -2
  17. package/docs/PROVIDER_HEALTH.md +26 -2
  18. package/docs/ROADMAP.md +5 -3
  19. package/docs/SECURITY.md +12 -2
  20. package/docs/VISION_CLI.md +2 -2
  21. package/docs/releases/0.2.6.md +50 -0
  22. package/lib/actions.js +137 -30
  23. package/lib/adapters.js +36 -9
  24. package/lib/api.js +2 -1
  25. package/lib/client.js +717 -180
  26. package/lib/composite-vision-routing.js +6 -6
  27. package/lib/config.js +140 -29
  28. package/lib/doctor.js +8 -1
  29. package/lib/dsh-core-adapter.js +22 -11
  30. package/lib/dsh-host-adapter.js +10 -0
  31. package/lib/dsh-provider-config.js +35 -0
  32. package/lib/dsh-provider-source.js +102 -0
  33. package/lib/headless-vision.js +5 -5
  34. package/lib/host-contract.js +1 -0
  35. package/lib/http-vision-model-adapter.js +5 -2
  36. package/lib/index.js +54 -35
  37. package/lib/model-directory-service.js +53 -0
  38. package/lib/model-directory.js +103 -0
  39. package/lib/models.js +71 -6
  40. package/lib/ocr-model-routing.js +2 -2
  41. package/lib/provider-adapters.js +42 -9
  42. package/lib/provider-catalog.js +14 -9
  43. package/lib/provider-config-scope.js +16 -0
  44. package/lib/provider-config-service.js +82 -13
  45. package/lib/provider-health.js +33 -0
  46. package/lib/provider-protocol.js +30 -0
  47. package/lib/vision-model-routing.js +25 -12
  48. package/package.json +2 -2
  49. package/user_guide.md +25 -19
package/lib/actions.js CHANGED
@@ -31,13 +31,21 @@ import { formatOcrResult } from './ocr.js';
31
31
  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
- import { selectMediaProtocol, selectImageProtocol, providerForImageModel, supportsImageEdit } from './provider-protocol.js';
34
+ import { selectMediaProtocol, selectImageProtocol, providerForImageModel, supportsImageEdit, supportsModelCapability, dashscopeTtsProtocolIssue, dashscopeTtsInputIssue } from './provider-protocol.js';
35
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';
39
39
  import { normalizeGenerationInput } from './generation-input.js';
40
40
  import { videoTaskCandidates } from './video-input.js';
41
+ import { normalizeDshProvider, withDshProviderConfig } from './dsh-provider-config.js';
42
+ import { modelDirectorySummary, modelUnavailableReason } from './model-directory.js';
43
+
44
+ const PROVIDER_ACTIONS = new Set(['image', 'image_edit', 'video', 'music', 'tts', 'transcribe', 'look', 'relook',
45
+ 'locate', 'ocr', 'media_summarize', 'task_reobserve', 'task_redeliver', 'task_manual_retry',
46
+ 'providers_discover', 'providers_sync_directory', 'providers_test_vision', 'providers_test_model']);
47
+ const AUTO_DIRECTORY_ACTIONS = new Set(['image', 'image_edit', 'video', 'music', 'tts', 'transcribe', 'look', 'relook',
48
+ 'locate', 'ocr', 'media_summarize']);
41
49
 
42
50
  const MEDIA_TYPES = {
43
51
  '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
@@ -46,6 +54,7 @@ const MEDIA_TYPES = {
46
54
 
47
55
  function createConfiguredProviderAdapter(provider, options = {}) {
48
56
  return createProviderAdapter(provider, { ...options,
57
+ onModelAvailability: event => store.recordAvailability(event),
49
58
  onRateLimit: options.allowRateLimited ? undefined : store.recordRateLimit });
50
59
  }
51
60
 
@@ -92,11 +101,14 @@ function throwIfAborted(signal) {
92
101
  * 不因后续轮询或结果下载失败重提,避免同一请求重复计费。
93
102
  */
94
103
  function selectProviders(capability, label, requestedModelRef) {
104
+ const policy = models.functionPolicy(store.assignments()[capability], capability);
105
+ if (policy.mode === 'disabled') throw new Error('iris: 没有可用' + label + '模型:此功能已停用,请在功能池中选择调用方式');
95
106
  let providers = store.pickAllFor(capability);
96
107
  const requested = String(requestedModelRef || '').trim();
97
108
  if (requested) {
98
- const pool = models.orderedModels(models.modelPool(store.providers()), capability, store.assignments()[capability], { includeRateLimited: true });
109
+ const pool = models.orderedModels(models.functionModelPool(store.providers(), capability, store.assignments()[capability]), capability, store.assignments()[capability], { includeRateLimited: true, includeUnavailable: true });
99
110
  const selected = models.resolvePoolModel(pool, requested, capability);
111
+ if (selected?.unavailableCapabilities?.includes(capability)) throw new Error('iris: ' + modelUnavailableReason());
100
112
  if (selected?.rateLimited) throw Object.assign(new Error(store.modelHealth(selected.providerId, selected.id, capability).retryAt
101
113
  ? 'iris: 指定模型正在限流冷却,请稍后使用或手动实测'
102
114
  : 'iris: 指定模型额度或预算耗尽,请手动实测成功后再使用'), {
@@ -106,7 +118,7 @@ function selectProviders(capability, label, requestedModelRef) {
106
118
  providers = selected ? providers.filter((p) => p.id === selected.providerId && p[field] === selected.id) : [];
107
119
  if (!providers.length) throw new Error('iris: 指定模型未分配给该功能、不可用或不具备' + label + '能力');
108
120
  }
109
- if (!providers.length) throw new Error('iris: 没有可用' + label + '供应商——先在 Iris 设置里添加、标注并分配模型');
121
+ if (!providers.length) throw new Error('iris: 没有可用' + label + '模型:请检查连接、已确认的模型用途、协议和冷却状态;手选模式还需选择模型');
110
122
  return providers;
111
123
  }
112
124
 
@@ -161,13 +173,13 @@ async function observeProviderCapability(route, capability, operation, { recordS
161
173
  const result = await operation();
162
174
  if (recordSuccess && result && ['accepted', 'completed', 'pending', 'succeeded'].includes(result.kind)) {
163
175
  recordProviderHealthSafe(route.provider.id, route.model, capability, {
164
- ok: true,
176
+ ok: true, stage: 'poll',
165
177
  category: 'success',
166
178
  note: result.kind === 'accepted' ? '任务已受理' : '供应商请求成功'
167
179
  });
168
180
  } else if (result && result.error) {
169
181
  recordProviderHealthSafe(route.provider.id, route.model, capability, {
170
- ok: false,
182
+ ok: false, stage: 'poll',
171
183
  category: result.error.category,
172
184
  httpStatus: result.error.httpStatus,
173
185
  note: result.error.safeMessage
@@ -176,7 +188,7 @@ async function observeProviderCapability(route, capability, operation, { recordS
176
188
  return result;
177
189
  } catch (error) {
178
190
  recordProviderHealthSafe(route.provider.id, route.model, capability, {
179
- ok: false,
191
+ ok: false, stage: 'poll',
180
192
  category: error && error.category,
181
193
  httpStatus: error && (error.httpStatus ?? error.status),
182
194
  note: String(error && (error.safeMessage || error.message) || error)
@@ -200,7 +212,8 @@ function recordVisionHealth(errors) {
200
212
 
201
213
  async function askSingleVisionWithHealth(host, request) {
202
214
  try {
203
- const result = await runVisionRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, ...request });
215
+ const result = await runVisionRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit,
216
+ onModelAvailability: store.recordAvailability, ...request });
204
217
  recordVisionHealth(result.errors);
205
218
  if (result.via === 'selfstack') recordProviderHealthSafe(result.backendId, result.model, cap.CAPABILITIES.VISION, {
206
219
  ok: true, category: 'success', note: '视觉请求成功'
@@ -462,7 +475,14 @@ export function listActions() {
462
475
  export async function runAction(host, name, args, { signal } = {}) {
463
476
  const fn = actions[name];
464
477
  if (!fn) throw new Error(`iris: 未知动作 "${name}"`);
465
- return fn(actionHost(host), args || {}, { signal });
478
+ const adapter = actionHost(host);
479
+ const execute = async () => {
480
+ if (AUTO_DIRECTORY_ACTIONS.has(name) && !args?.model) await store.refreshAutomaticDirectories({
481
+ capability: ({ image: 'image-gen', image_edit: 'image-gen', video: 'video-gen', music: 'music-gen', tts: 'tts', transcribe: 'transcribe' })[name] || 'vision', signal
482
+ });
483
+ return fn(adapter, args || {}, { signal });
484
+ };
485
+ return PROVIDER_ACTIONS.has(name) ? withDshProviderConfig(adapter, execute, { signal }) : execute();
466
486
  }
467
487
 
468
488
  /* ============ 动作实现 ============ */
@@ -585,7 +605,7 @@ register('music', async (host, args, { signal }) => {
585
605
  ...(optionalActionText(args.retry_502) !== undefined ? { retry_502: args.retry_502 } : {}),
586
606
  ...(optionalActionText(args.retry_delay_ms) !== undefined ? { retry_delay_ms: args.retry_delay_ms } : {})
587
607
  });
588
- const routes = musicCandidatesFromCatalog({ providers: store.allProviders(), assignments: store.assignments() }, args.model);
608
+ const routes = musicCandidatesFromCatalog({ providers: store.runtimeProviders(), assignments: store.assignments() }, args.model);
589
609
  const submitted = await submitMusicForDsh({
590
610
  candidates: routes.map(route => ({ adapter: createConfiguredProviderAdapter(route.provider), model: route.modelRef,
591
611
  selectionReason: route.selectionReason, providerBinding: providerTaskBinding(route.provider) })),
@@ -721,7 +741,8 @@ register('locate', async (host, args, { signal }) => {
721
741
  const p = String(args.image_path || '').trim();
722
742
  if (source === 'image_path' && (!p || !path.isAbsolute(p))) throw new Error('iris: image_path 必须是绝对路径');
723
743
  const mt = MEDIA_TYPES[path.extname(p).toLowerCase()] || 'image/png';
724
- const r = await runLocateRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit, model: args.model,
744
+ const r = await runLocateRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit,
745
+ onModelAvailability: store.recordAvailability, model: args.model,
725
746
  target: args.target, prepareImage: operationSignal => source === 'artifact_id'
726
747
  ? visionImageForDsh({ artifactId: args.artifact_id, signal: operationSignal }) : readVisionImageFile(p, mt, operationSignal), signal });
727
748
  const { width, height } = r;
@@ -750,7 +771,7 @@ register('ocr', async (host, args, { signal }) => {
750
771
  if (!mediaType || mediaType === 'image/gif') throw new Error('iris: 不支持的图片格式(png/jpg/jpeg/webp)');
751
772
  }
752
773
  const result = await runOcrRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION),
753
- onRateLimit: store.recordRateLimit,
774
+ onRateLimit: store.recordRateLimit, onModelAvailability: store.recordAvailability,
754
775
  prepareImage: operationSignal => source === 'artifact_id'
755
776
  ? visionImageForDsh({ artifactId: args.artifact_id, signal: operationSignal }) : readVisionImageFile(p, mediaType, operationSignal),
756
777
  chunkHeight: args.chunk_height, overlap: args.overlap, signal });
@@ -1032,22 +1053,32 @@ register('relook', async (host, args, { signal }) => {
1032
1053
  /** 列出全部供应商及其模型池(管理 GUI 用;apiKey 只给 hint) */
1033
1054
  register('providers_list', async (host, args) => {
1034
1055
  const list = store.allProviders();
1056
+ const connections = list.some(provider => provider.dshProvider) && hasHostPort(host, 'providerConnections')
1057
+ ? await host.ports.providerConnections.list() : [];
1035
1058
  return {
1036
1059
  ok: true,
1060
+ setupSuggested: Object.keys(store.assignments()).length === 0 && list.every(provider => models.providerModels(provider).length === 0),
1037
1061
  providers: list.map((p) => ({
1038
1062
  id: p.id,
1039
1063
  name: p.name || p.id,
1040
1064
  type: p.type || 'openai',
1041
- baseUrl: p.baseUrl,
1065
+ baseUrl: p.dshProvider ? (connections.find(item => item.provider === p.dshProvider.provider
1066
+ && item.settingsNs === p.dshProvider.settingsNs)?.baseUrl || '') : p.baseUrl,
1067
+ ...(p.dshProvider ? { dshProvider: p.dshProvider,
1068
+ connection: connections.find(item => item.provider === p.dshProvider.provider && item.settingsNs === p.dshProvider.settingsNs)
1069
+ || { available: false, reason: 'DSH 原连接不可用,请检查宿主供应商设置' } } : {}),
1042
1070
  mediaBaseUrl: p.mediaBaseUrl || '',
1043
1071
  enabled: p.enabled !== false,
1044
1072
  auth: p.auth === 'none' ? 'none' : 'bearer',
1045
1073
  ...selectMediaProtocol(p),
1046
1074
  visionInput: p.visionInput || null,
1047
- apiKeyHint: String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
1048
- models: models.providerModels(p).map((m) => ({
1075
+ directory: modelDirectorySummary(p),
1076
+ apiKeyHint: p.dshProvider ? '由 DSH 管理' : String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
1077
+ models: models.providerModelsWithDirectory(p).map((m) => ({
1049
1078
  id: m.id,
1050
1079
  capabilities: m.capabilities,
1080
+ ...(m.directoryOnly ? { directoryOnly: true } : {}),
1081
+ ...(m.catalogStatus ? { catalogStatus: m.catalogStatus } : {}),
1051
1082
  ...(m.imageProtocol !== undefined ? { imageProtocol: m.imageProtocol } : {}),
1052
1083
  ...(m.musicProtocol !== undefined ? { musicProtocol: m.musicProtocol } : {}),
1053
1084
  ...(m.visionInput !== undefined ? { visionInput: m.visionInput } : {}),
@@ -1064,6 +1095,12 @@ register('providers_list', async (host, args) => {
1064
1095
  };
1065
1096
  });
1066
1097
 
1098
+ register('providers_host_connections', async host => {
1099
+ if (!hasHostPort(host, 'providerConnections')) return { ok: true, connections: [],
1100
+ note: '当前 DSH 未提供连接复用接口,请手动添加 Iris 供应商' };
1101
+ return { ok: true, connections: await host.ports.providerConnections.list() };
1102
+ });
1103
+
1067
1104
  /** 新增/更新供应商 */
1068
1105
  register('providers_upsert', async (host, args) => {
1069
1106
  const id = args.id || undefined;
@@ -1073,6 +1110,16 @@ register('providers_upsert', async (host, args) => {
1073
1110
  for (const key of allowed) {
1074
1111
  if (Object.prototype.hasOwnProperty.call(args, key)) patch[key] = args[key];
1075
1112
  }
1113
+ if (Object.hasOwn(args, 'dshProvider')) {
1114
+ patch.dshProvider = normalizeDshProvider(args.dshProvider);
1115
+ const source = requireHostPort(host, 'providerConnections', '复用 DSH 供应商连接');
1116
+ const connection = (await source.list()).find(item => item.provider === patch.dshProvider.provider
1117
+ && item.settingsNs === patch.dshProvider.settingsNs);
1118
+ if (!connection?.available) throw new Error(connection?.reason || 'DSH 连接不可用');
1119
+ if (!['dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images'].includes(patch.mediaProtocol)) {
1120
+ throw new Error('复用 DSH 连接时请明确选择媒体协议');
1121
+ }
1122
+ }
1076
1123
  if (!id && !Object.prototype.hasOwnProperty.call(patch, 'enabled')) patch.enabled = true;
1077
1124
  const saved = store.upsert(patch);
1078
1125
  return { ok: true, providerId: saved.id };
@@ -1098,21 +1145,27 @@ register('providers_set_models', async (host, args) => {
1098
1145
  register('providers_discover', async (host, args, { signal }) => {
1099
1146
  const p = store.providerById(String(args.id || ''));
1100
1147
  if (!p) throw new Error('iris: 供应商不存在或未启用');
1101
- let discoveredModels;
1102
- try {
1103
- const providerAdapter = createConfiguredProviderAdapter(p);
1104
- const discovered = await invokeProviderOperation(providerAdapter, 'discover', { signal });
1105
- discoveredModels = discovered.models;
1106
- } catch (err) {
1107
- throw new Error('iris: 模型发现失败(保留现有池):' + String(err.message || err));
1108
- }
1109
- const entries = discoveredModels.map((model) => ({ id: model.id, capabilities: models.capabilitiesOfDiscoveredModel(model) }));
1148
+ const [updated] = await store.refreshProviderDirectories({ providerId: p.id, force: true, signal });
1149
+ if (updated.error) throw new Error('iris: 模型发现失败(保留现有池):' + updated.error);
1150
+ const entries = updated.directory.models;
1110
1151
  const mediaCount = entries.filter((model) => model.capabilities.length).length;
1111
1152
  store.mergeDiscoveredModels(p.id, entries);
1112
- return { ok: true, total: discoveredModels.length, media: mediaCount,
1153
+ return { ok: true, total: entries.length, media: mediaCount,
1113
1154
  text: `[iris] ${p.name}:发现 ${entries.length} 个模型,${mediaCount} 个有能力声明;未标注模型请在模型池中设置能力` };
1114
1155
  });
1115
1156
 
1157
+ /** 只更新目录快照;新增项在管理视图待确认,不改模型用途与功能名单。 */
1158
+ register('providers_sync_directory', async (host, args, { signal }) => {
1159
+ const id = String(args.id || '').trim();
1160
+ if (!store.providerById(id)) throw new Error('iris: 供应商不存在或连接未就绪');
1161
+ const [result] = await store.refreshProviderDirectories({ providerId: id, force: args.force === true, signal });
1162
+ if (result.error) return { ok: true, synced: false, error: result.error,
1163
+ text: '目录同步失败,已保留上次完整目录:' + result.error };
1164
+ return { ok: true, synced: true, refreshed: result.refreshed,
1165
+ text: result.refreshed ? '模型目录已同步;新模型仍需确认用途' : '目录仍在有效期内',
1166
+ directory: modelDirectorySummary(store.providerById(id)) };
1167
+ });
1168
+
1116
1169
  /** 能力实测(红色测试图):对某供应商的 vision 模型跑 testVisionCapability */
1117
1170
  register('providers_test_vision', async (host, args, { signal }) => {
1118
1171
  const p = store.providerById(String(args.id || ''));
@@ -1121,7 +1174,7 @@ register('providers_test_vision', async (host, args, { signal }) => {
1121
1174
  if (!vision) return { ok: true, tested: false, text: '该供应商未配置 vision 模型,跳过' };
1122
1175
  const model = vision.id;
1123
1176
  if (args.confirm_paid !== true) throw new Error('iris: 视觉实测会发起真实供应商请求,请先明确确认');
1124
- const r = await probeVisionModel(p, model, visionImageFromDataUrl(RED_TEST_IMAGE), { timeoutMs: 15000, signal });
1177
+ const r = await probeVisionModel(p, model, visionImageFromDataUrl(RED_TEST_IMAGE), { timeoutMs: 15000, signal, onModelAvailability: store.recordAvailability });
1125
1178
  store.setModelVerified(p.id, model, cap.CAPABILITIES.VISION, {
1126
1179
  ok: r.ok,
1127
1180
  note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色'),
@@ -1146,7 +1199,7 @@ async function probeModel(host, p, modelId, capability, signal) {
1146
1199
  return { skipped: true, ok: false, note: 'Music 不发起小样本探测;请通过音乐功能提交实际创作要求' };
1147
1200
  }
1148
1201
  if (capability === cap.CAPABILITIES.VISION) {
1149
- const r = await probeVisionModel(p, modelId, visionImageFromDataUrl(RED_TEST_IMAGE), { timeoutMs: 15000, signal });
1202
+ const r = await probeVisionModel(p, modelId, visionImageFromDataUrl(RED_TEST_IMAGE), { timeoutMs: 15000, signal, onModelAvailability: store.recordAvailability });
1150
1203
  return {
1151
1204
  ok: r.ok,
1152
1205
  note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色'),
@@ -1158,6 +1211,8 @@ async function probeModel(host, p, modelId, capability, signal) {
1158
1211
  return { skipped: true, ok: false, note: '需真实音频;可用 CLI models test --capability transcribe --input 提供素材进行实测恢复' };
1159
1212
  }
1160
1213
  if (capability === cap.CAPABILITIES.TTS) {
1214
+ const issue = selectMediaProtocol(p).mediaProtocol === 'dashscope' && dashscopeTtsInputIssue(modelId);
1215
+ if (issue) return { skipped: true, ok: false, note: issue };
1161
1216
  const providerAdapter = createConfiguredProviderAdapter(p, { allowRateLimited: true });
1162
1217
  const submitted = await invokeProviderOperation(providerAdapter, 'submit', {
1163
1218
  capability: 'tts', model: modelId, input: { text: '你好' }, signal
@@ -1231,6 +1286,14 @@ register('providers_add_model', async (host, args) => {
1231
1286
  return { ok: true, text: `[iris] 已添加模型 ${modelId}(能力:${(caps || []).join(',') || '未标注'})` };
1232
1287
  });
1233
1288
 
1289
+ register('providers_confirm_model', async (_host, args) => {
1290
+ const entry = store.confirmModelUsage(args.id, args.model_id, args.capabilities, {
1291
+ enableAutomatic: args.enableAutomatic, musicProtocol: args.musicProtocol
1292
+ });
1293
+ return { ok: true, model_id: entry.id, text: args.enableAutomatic
1294
+ ? '已确认模型用途,并启用所选功能的自动池' : '已确认模型用途;已有调用方式保持' };
1295
+ });
1296
+
1234
1297
  /** 从池移除模型 */
1235
1298
  register('providers_remove_model', async (host, args) => {
1236
1299
  const ok = store.removeProviderModel(String(args.id || ''), String(args.model_id || ''));
@@ -1303,22 +1366,62 @@ register('providers_test_model', async (host, args, { signal }) => {
1303
1366
  register('assignments_get', async (host, args) => {
1304
1367
  const providers = store.allProviders();
1305
1368
  const configured = new Set(store.providers().map(provider => provider.id));
1369
+ const connections = providers.some(provider => provider.dshProvider) && hasHostPort(host, 'providerConnections')
1370
+ ? await host.ports.providerConnections.list() : [];
1371
+ for (const provider of providers) {
1372
+ if (provider.enabled !== false && provider.dshProvider && connections.some(item => item.available
1373
+ && item.provider === provider.dshProvider.provider && item.settingsNs === provider.dshProvider.settingsNs)) {
1374
+ configured.add(provider.id);
1375
+ }
1376
+ }
1377
+ const previewProviders = providers.map(provider => provider.dshProvider ? { ...provider,
1378
+ baseUrl: connections.find(item => item.provider === provider.dshProvider.provider
1379
+ && item.settingsNs === provider.dshProvider.settingsNs)?.baseUrl } : provider);
1306
1380
  const pool = models.modelPool(providers, { includeHostVision: true });
1307
1381
  const byCap = {};
1308
1382
  for (const m of pool) {
1309
1383
  for (const c of m.capabilities) {
1310
1384
  if (!byCap[c]) byCap[c] = [];
1311
- const provider = providers.find(provider => provider.id === m.providerId);
1385
+ const provider = previewProviders.find(provider => provider.id === m.providerId);
1386
+ const hostResource = m.ref === models.HOST_VISION_REF;
1387
+ const protocolSupported = hostResource || supportsModelCapability(provider, m.id, c);
1388
+ const modelUnavailable = m.unavailableCapabilities?.includes(c) === true;
1389
+ const connected = hostResource ? hasHostPort(host, 'textModel') : configured.has(m.providerId);
1312
1390
  byCap[c].push({ ref: m.ref, id: m.id, providerId: m.providerId,
1313
1391
  label: m.ref === models.HOST_VISION_REF ? 'DSH 当前默认模型' : m.id + ' · ' + m.providerId,
1314
- unavailable: m.ref !== models.HOST_VISION_REF && (!configured.has(m.providerId) || m.rateLimited === true),
1392
+ unavailable: !connected || !protocolSupported || m.rateLimited === true || modelUnavailable,
1393
+ modelUnavailable,
1394
+ pendingConfirmation: m.source === 'discovered', protocolSupported,
1395
+ reason: !connected ? '连接未就绪,请检查供应商或宿主服务' : !protocolSupported
1396
+ ? (c === cap.CAPABILITIES.TTS && selectMediaProtocol(provider).mediaProtocol === 'dashscope'
1397
+ && dashscopeTtsProtocolIssue(m.id)) || '当前接口协议不支持此功能'
1398
+ : modelUnavailable ? modelUnavailableReason() : m.rateLimited ? '限流冷却或额度不可用,请稍后或检查账号' : m.source === 'discovered' ? '已发现,待确认模型用途' : '',
1315
1399
  ...(c === cap.CAPABILITIES.IMAGE && provider ? { imageRouting: imageRoutingForModel(provider, m.id) } : {}) });
1316
1400
  }
1317
1401
  }
1318
1402
  // order:归一化的有序分配列表(GUI 直接渲染 failover 顺序,无需处理单字符串旧格式)
1319
1403
  const order = {};
1320
1404
  for (const c of Object.values(cap.CAPABILITIES)) order[c] = store.assignmentOrder(c);
1321
- return { ok: true, assignments: store.assignments(), order, poolByCapability: byCap };
1405
+ const policies = {}, readiness = {};
1406
+ for (const capability of Object.values(cap.CAPABILITIES)) {
1407
+ const assignment = store.assignments()[capability];
1408
+ const policy = models.functionPolicy(assignment, capability);
1409
+ policies[capability] = policy;
1410
+ if (policy.mode === 'auto') order[capability] = models.orderedModels(
1411
+ models.functionModelPool(previewProviders, capability, assignment, { includeHostVision: true }),
1412
+ capability, assignment, { includeRateLimited: true, includeUnavailable: true }).map(model => model.ref);
1413
+ const options = byCap[capability] || [];
1414
+ const candidates = options.filter(model => order[capability].includes(model.ref) && !model.unavailable);
1415
+ let note = '';
1416
+ if (policy.mode === 'disabled') note = '此功能已停用,可选择自动池或手选模型';
1417
+ else if (policy.mode === 'manual' && !order[capability].length) note = '尚未选择模型,可添加所选项或改用自动池';
1418
+ else if (!options.length) note = '请先添加模型,并确认它可用于此功能';
1419
+ else if (!candidates.length) note = options.some(model => model.pendingConfirmation)
1420
+ ? '发现的模型尚待确认用途,请在供应商中确认功能'
1421
+ : options.find(model => model.reason)?.reason || '当前没有匹配的候选,请检查功能范围和模型';
1422
+ readiness[capability] = { mode: policy.mode, candidateCount: candidates.length, note };
1423
+ }
1424
+ return { ok: true, assignments: store.assignments(), order, poolByCapability: byCap, policies, readiness };
1322
1425
  });
1323
1426
 
1324
1427
  /**
@@ -1329,6 +1432,10 @@ register('assignments_get', async (host, args) => {
1329
1432
  register('assignments_set', async (host, args) => {
1330
1433
  const capability = String(args.capability || '').trim();
1331
1434
  if (!capability) throw new Error('iris: capability 不能为空');
1435
+ if (Object.hasOwn(args, 'mode')) {
1436
+ const policy = store.setFunctionPolicy(capability, args.mode, { hostVision: args.hostVision });
1437
+ return { ok: true, capability, mode: policy.mode, model_refs: store.assignmentOrder(capability) };
1438
+ }
1332
1439
  const listArg = Array.isArray(args.model_refs) ? args.model_refs : args.model_ids;
1333
1440
  if (Array.isArray(listArg)) {
1334
1441
  const refs = listArg.map((x) => String(x || '').trim()).filter(Boolean);
@@ -1465,7 +1572,7 @@ register('media_summarize', async (host, args, { signal }) => {
1465
1572
  if (!ffmpegAvailable()) throw new Error('iris: ffmpeg/ffprobe 不可用,无法分析视频(可选安装 ffmpeg 后启用)');
1466
1573
  }
1467
1574
  const { answer, via, model, sheet, meta, frames } = await runSummaryRequest(host, {
1468
- onRateLimit: store.recordRateLimit,
1575
+ onRateLimit: store.recordRateLimit, onModelAvailability: store.recordAvailability,
1469
1576
  providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model, question: args.question, signal,
1470
1577
  async prepareMedia(operationSignal) {
1471
1578
  if (source === 'core-artifacts') {
package/lib/adapters.js CHANGED
@@ -367,7 +367,15 @@ export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', t
367
367
  body: JSON.stringify({ model, input: { text, voice } })
368
368
  });
369
369
  const body = await readJson(res);
370
- if (!res.ok) throw providerError(res.status, body, '合成失败', res.headers);
370
+ if (!res.ok) {
371
+ const error = providerError(res.status, body, '合成失败', res.headers);
372
+ if (res.status === 404 && (/NotFound\.Model|model_not_found/i.test(error.providerCode)
373
+ || /model (?:not exist|can not be found|.*does not exist)/i.test(String(error.rootCause)))) {
374
+ error.category = 'invalid_parameter';
375
+ error.message = `当前语音接口找不到模型 ${model}(${new URL(apiBase).hostname});请核对模型 ID、所属地域及调用协议。上游:${error.rootCause}`;
376
+ }
377
+ throw error;
378
+ }
371
379
  const audio = body && body.output && body.output.audio;
372
380
  const url = audio && (audio.url || audio.audio_url);
373
381
  const b64 = audio && typeof audio.data === 'string' ? audio.data : null;
@@ -510,6 +518,7 @@ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal })
510
518
  const found = new Map();
511
519
  const maxPages = dashscope ? 50 : 1;
512
520
  let loaded = 0;
521
+ let expectedTotal = null;
513
522
  for (let page = 1; page <= maxPages; page++) {
514
523
  const url = new URL(endpoint);
515
524
  if (dashscope) {
@@ -519,14 +528,24 @@ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal })
519
528
  const res = await fetch(url, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
520
529
  const body = await readJson(res);
521
530
  if (!res.ok) throw providerError(res.status, body, '模型发现失败', res.headers);
531
+ if (!body || body.success === false) throw new ProviderContractError('模型目录响应无效,已保留上次完整目录', {
532
+ stage: 'validate', category: 'protocol', acceptance: 'not_accepted'
533
+ });
522
534
  const output = body && body.output;
523
535
  const items = (output && Array.isArray(output.models) && output.models)
524
536
  || (body && Array.isArray(body.data) && body.data)
525
- || (Array.isArray(body && body.models) ? body.models : []);
537
+ || (Array.isArray(body && body.models) ? body.models : null);
538
+ if (!items) throw new ProviderContractError('模型目录缺少模型数组,已保留上次完整目录', {
539
+ stage: 'validate', category: 'protocol', acceptance: 'not_accepted'
540
+ });
526
541
  loaded += items.length;
527
542
  for (const item of items) {
528
- const id = String(typeof item === 'string' ? item : item?.id || item?.model || '').trim();
529
- if (!id || found.has(id)) continue;
543
+ const rawId = typeof item === 'string' ? item : item?.id || item?.model;
544
+ if (typeof rawId !== 'string' || !rawId.trim()) throw new ProviderContractError('模型目录包含无效模型 ID,已保留上次完整目录', {
545
+ stage: 'validate', category: 'protocol', acceptance: 'not_accepted'
546
+ });
547
+ const id = rawId.trim();
548
+ if (found.has(id)) continue;
530
549
  const capabilities = Array.isArray(item?.capabilities)
531
550
  ? item.capabilities.filter((value) => typeof value === 'string' && value)
532
551
  : [];
@@ -538,17 +557,25 @@ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal })
538
557
  if (!Array.isArray(item?.capabilities)) {
539
558
  if (responseModalities.includes('image')) capabilities.push('IG');
540
559
  if (responseModalities.includes('video')) capabilities.push('VG');
541
- if (requestModalities.includes('audio') && responseModalities.includes('text')) capabilities.push('ASR');
542
- if (requestModalities.includes('text') && responseModalities.includes('audio')) capabilities.push('TTS');
560
+ // 音频理解不证明支持文件转写;混合视频/图像/文本输出也不证明独立TTS。
561
+ if (requestModalities.length === 1 && requestModalities[0] === 'text'
562
+ && responseModalities.length === 1 && responseModalities[0] === 'audio') capabilities.push('TTS');
543
563
  if (requestModalities.includes('image') && responseModalities.includes('text')) capabilities.push('VU');
544
564
  }
545
565
  const normalizedCapabilities = [...new Set(capabilities)];
546
566
  found.set(id, normalizedCapabilities.length ? { id, capabilities: normalizedCapabilities } : id);
547
567
  }
548
568
  if (!dashscope) break;
549
- const statedTotal = Number(output?.total);
550
- const total = Number.isFinite(statedTotal) && statedTotal >= 0 ? statedTotal : null;
551
- if (!items.length || (total !== null && loaded >= total) || items.length < 100) break;
569
+ const total = output?.total === undefined ? expectedTotal : Number(output.total);
570
+ if (total !== null && (!Number.isSafeInteger(total) || total < 0
571
+ || (expectedTotal !== null && total !== expectedTotal) || loaded > total || (!items.length && loaded < total)
572
+ || (loaded === total && found.size !== loaded))) {
573
+ throw new ProviderContractError('模型目录分页不完整或总数变化,已保留上次完整目录', {
574
+ stage: 'validate', category: 'protocol', acceptance: 'not_accepted'
575
+ });
576
+ }
577
+ if (total !== null) expectedTotal = total;
578
+ if ((total !== null && loaded === total) || (total === null && items.length < 100)) break;
552
579
  if (page === maxPages) throw new Error('iris: 模型目录超过安全分页上限,已保留原模型池');
553
580
  }
554
581
  return [...found.values()];
package/lib/api.js CHANGED
@@ -66,7 +66,8 @@ function providerPublic(p) {
66
66
  mediaBaseUrl: p.mediaBaseUrl || '',
67
67
  enabled: p.enabled !== false,
68
68
  ...selectMediaProtocol(p),
69
- apiKeyHint: keyHint(p.apiKey),
69
+ apiKeyHint: p.dshProvider ? '由 DSH 管理' : keyHint(p.apiKey),
70
+ ...(p.dshProvider ? { dshProvider: p.dshProvider } : {}),
70
71
  capabilities: capabilitiesOf(p),
71
72
  // 迁移/推断结果是否与原始声明不同(UI 提示旧配置已推断)
72
73
  capabilityInferred: !Array.isArray(p.capabilities) || p.capabilities.length === 0,