@mokuyoaxis/dsh-iris 0.2.4 → 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 (53) 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 +38 -1
  4. package/README.en.md +34 -41
  5. package/README.md +35 -40
  6. package/bin/dsh-iris.js +29 -12
  7. package/docs/AGENT_QUICKSTART.md +11 -13
  8. package/docs/ARCHITECTURE.md +1 -1
  9. package/docs/CLI_MANAGEMENT.md +5 -1
  10. package/docs/DSH_COMPATIBILITY.md +38 -8
  11. package/docs/DSH_PROVIDER_CONNECTIONS.md +34 -0
  12. package/docs/DSH_RC2_ADAPTATION.md +1 -1
  13. package/docs/FUNCTION_MODEL_ASSIGNMENTS.md +2 -0
  14. package/docs/FUNCTION_MODEL_POOLS.md +84 -0
  15. package/docs/HEADLESS_CLI.md +7 -7
  16. package/docs/HOST_ADAPTER_CONTRACT.md +8 -2
  17. package/docs/HOST_DOCTOR.md +3 -3
  18. package/docs/MUSIC.md +2 -2
  19. package/docs/PROVIDER_HEALTH.md +26 -2
  20. package/docs/ROADMAP.md +8 -4
  21. package/docs/SECURITY.md +12 -2
  22. package/docs/VISION_CLI.md +2 -2
  23. package/docs/releases/0.2.5.md +33 -0
  24. package/docs/releases/0.2.6.md +50 -0
  25. package/lib/actions.js +137 -30
  26. package/lib/adapters.js +36 -9
  27. package/lib/api.js +2 -1
  28. package/lib/client.js +717 -180
  29. package/lib/composite-vision-routing.js +6 -6
  30. package/lib/config.js +140 -29
  31. package/lib/doctor.js +9 -2
  32. package/lib/dsh-compatibility.js +7 -1
  33. package/lib/dsh-core-adapter.js +22 -11
  34. package/lib/dsh-host-adapter.js +32 -29
  35. package/lib/dsh-provider-config.js +35 -0
  36. package/lib/dsh-provider-source.js +102 -0
  37. package/lib/headless-vision.js +5 -5
  38. package/lib/host-contract.js +1 -0
  39. package/lib/http-vision-model-adapter.js +5 -2
  40. package/lib/index.js +54 -35
  41. package/lib/model-directory-service.js +53 -0
  42. package/lib/model-directory.js +103 -0
  43. package/lib/models.js +71 -6
  44. package/lib/ocr-model-routing.js +2 -2
  45. package/lib/provider-adapters.js +42 -9
  46. package/lib/provider-catalog.js +14 -9
  47. package/lib/provider-config-scope.js +16 -0
  48. package/lib/provider-config-service.js +82 -13
  49. package/lib/provider-health.js +33 -0
  50. package/lib/provider-protocol.js +30 -0
  51. package/lib/vision-model-routing.js +25 -12
  52. package/package.json +9 -9
  53. package/user_guide.md +28 -29
@@ -8,10 +8,10 @@ import { VISION_BUDGET } from './vision-core.js';
8
8
  import { normalizeModelCall } from './model-port-contract.js';
9
9
  import { waitForModelWork } from './model-call-runtime.js';
10
10
 
11
- export async function runLocateRequest(host, { providers = [], model, target, image, prepareImage, signal, budget = VISION_BUDGET, onRateLimit }) {
11
+ export async function runLocateRequest(host, { providers = [], model, target, image, prepareImage, signal, budget = VISION_BUDGET, onRateLimit, onModelAvailability }) {
12
12
  target = normalizeLocateTarget(target);
13
13
  const inputs = new Map();
14
- const result = await runVisionOperation(host, { providers, model, signal, budget, onRateLimit, prepareImages: true,
14
+ const result = await runVisionOperation(host, { providers, model, signal, budget, onRateLimit, onModelAvailability, prepareImages: true,
15
15
  orientImages: true, prepareImageRequest: createLocateImageRequest(target, inputs) }, async (ports, operation) => {
16
16
  const resolved = await waitForModelWork(() => prepareImage ? prepareImage(operation.signal) : image, operation.signal);
17
17
  const source = normalizeModelCall(ports[0].describe(), { prompt: target, image: resolved }, { budget }).request.image;
@@ -23,8 +23,8 @@ export async function runLocateRequest(host, { providers = [], model, target, im
23
23
  return location;
24
24
  }
25
25
 
26
- export async function runSummaryRequest(host, { providers = [], model, prepareMedia, frames, question, transcript, signal, budget = VISION_BUDGET, onRateLimit }) {
27
- return runVisionOperation(host, { providers, model, signal, budget, onRateLimit }, async (ports, operation) => {
26
+ export async function runSummaryRequest(host, { providers = [], model, prepareMedia, frames, question, transcript, signal, budget = VISION_BUDGET, onRateLimit, onModelAvailability }) {
27
+ return runVisionOperation(host, { providers, model, signal, budget, onRateLimit, onModelAvailability }, async (ports, operation) => {
28
28
  const media = await waitForModelWork(() => prepareMedia ? prepareMedia(operation.signal) : { frames, transcript }, operation.signal);
29
29
  const completion = await summarizeMedia({ ports, frames: media.frames, question, transcript: media.transcript,
30
30
  operation, budget });
@@ -33,9 +33,9 @@ export async function runSummaryRequest(host, { providers = [], model, prepareMe
33
33
  }
34
34
 
35
35
  /** 增强描述沿用共享调用;失败不改变已经完成的媒体交付。 */
36
- export async function describeGeneratedImage(host, { providers = [], originalPrompt, image, prepareImage, signal, onRateLimit }) {
36
+ export async function describeGeneratedImage(host, { providers = [], originalPrompt, image, prepareImage, signal, onRateLimit, onModelAvailability }) {
37
37
  try {
38
- const result = await runVisionRequest(host, { providers, image, prepareImage, signal, onRateLimit,
38
+ const result = await runVisionRequest(host, { providers, image, prepareImage, signal, onRateLimit, onModelAvailability,
39
39
  question: `用不超过两句话描述这张图片的主题与构图。生成该图的提示词是:「${String(originalPrompt).slice(0, 200)}」` });
40
40
  return result.answer.slice(0, 300);
41
41
  } catch (_) { return ''; }
package/lib/config.js CHANGED
@@ -1,5 +1,8 @@
1
1
  'use strict';
2
2
  import { normalizeVisionInputLimits } from './vision-image-input.js';
3
+ import { scopedProvider } from './provider-config-scope.js';
4
+ import { currentModelDirectory, modelDirectoryBinding, modelAvailabilityBinding, modelUnavailable, modelUnavailableReason, validateModelDirectory } from './model-directory.js';
5
+ import { refreshModelDirectory } from './model-directory-service.js';
3
6
  /**
4
7
  * Iris 配置存储:$DSH_HOME/iris/v1/providers.json
5
8
  * Iris 独立持有配置;旧工作台仅作为用户显式指定的一次性导入来源。
@@ -19,7 +22,7 @@ import {
19
22
  healthObservationState,
20
23
  isDefinitiveHealthFailure,
21
24
  newestHealthEvidence,
22
- modelRateLimit, recordModelRateLimit, clearModelRateLimit, clearModelCapabilityHealth
25
+ modelRateLimit, recordModelRateLimit, clearModelRateLimit, clearModelCapabilityHealth, recordModelAvailability, resetHealthOnCriticalChange
23
26
  } from './provider-health.js';
24
27
 
25
28
  function isPlainObject(value) {
@@ -30,12 +33,21 @@ function validateStoredConfig(value) {
30
33
  if (!isPlainObject(value)) throw new Error('配置根节点必须是对象');
31
34
  if (!Array.isArray(value.providers)) throw new Error('providers 必须是数组');
32
35
  if (!isPlainObject(value.assignments)) throw new Error('assignments 必须是对象');
36
+ for (const [capability, assignment] of Object.entries(value.assignments)) models.functionPolicy(assignment, capability);
33
37
  for (const provider of value.providers) {
34
38
  if (!isPlainObject(provider)) throw new Error('provider 条目必须是对象');
39
+ validateModelDirectory(provider.modelDirectory);
35
40
  if (provider.id !== undefined && typeof provider.id !== 'string') throw new Error('provider.id 必须是字符串');
36
41
  if (provider.baseUrl !== undefined && typeof provider.baseUrl !== 'string') throw new Error('provider.baseUrl 必须是字符串');
37
42
  if (provider.mediaBaseUrl !== undefined && typeof provider.mediaBaseUrl !== 'string') throw new Error('provider.mediaBaseUrl 必须是字符串');
38
43
  if (provider.apiKey !== undefined && typeof provider.apiKey !== 'string') throw new Error('provider.apiKey 必须是字符串');
44
+ if (provider.dshProvider !== undefined) {
45
+ const ref = provider.dshProvider;
46
+ if (!isPlainObject(ref) || typeof ref.provider !== 'string' || !ref.provider.trim()
47
+ || typeof ref.settingsNs !== 'string' || !ref.settingsNs.trim()
48
+ || Object.keys(ref).some(key => !['provider', 'settingsNs'].includes(key))) throw new Error('DSH 连接引用无效');
49
+ if (provider.apiKey !== undefined || provider.baseUrl !== undefined) throw new Error('DSH 引用不能同时保存密钥或普通 Base URL');
50
+ }
39
51
  if (provider.models !== undefined && !Array.isArray(provider.models)) throw new Error('provider.models 必须是数组');
40
52
  for (const model of provider.models || []) {
41
53
  if (typeof model === 'string') continue;
@@ -58,6 +70,7 @@ function authModeOf(provider) {
58
70
  /** 该供应商是否缺少必需凭据('none' 认证永不缺)。 */
59
71
  function missingCredentials(provider) {
60
72
  if (!provider || authModeOf(provider) === 'none') return false;
73
+ if (provider.dshProvider) return false;
61
74
  return typeof provider.apiKey !== 'string' || !provider.apiKey.trim();
62
75
  }
63
76
 
@@ -172,7 +185,37 @@ export function importFromWorkbench(workbenchConfigPath) {
172
185
  }
173
186
 
174
187
  export function providers() {
175
- return load().providers.filter(isConfiguredProvider);
188
+ return runtimeProviders().filter(isConfiguredProvider);
189
+ }
190
+
191
+ /** Runtime copies may contain Host-resolved credentials; never persist these copies. */
192
+ export function runtimeProviders() {
193
+ return load().providers.map(scopedProvider);
194
+ }
195
+
196
+ /** 目录更新只保存可选元数据,不保存宿主解析出的连接副本。 */
197
+ export function saveModelDirectory(providerId, directory) {
198
+ const provider = load().providers.find(item => item.id === providerId);
199
+ const current = runtimeProviders().find(item => item.id === providerId);
200
+ if (!provider || !current || modelDirectoryBinding(current) !== directory.binding) return false;
201
+ provider.modelDirectory = directory;
202
+ // 同一次操作的已解析副本也使用最新快照。
203
+ if (current !== provider) current.modelDirectory = directory;
204
+ persist();
205
+ return true;
206
+ }
207
+
208
+ export async function refreshProviderDirectories({ providerId, force = false, signal } = {}) {
209
+ const selected = providers().filter(provider => !providerId || provider.id === providerId);
210
+ return Promise.all(selected.map(provider => refreshModelDirectory(provider, { force, signal,
211
+ save: directory => saveModelDirectory(provider.id, directory) })));
212
+ }
213
+
214
+ export async function refreshAutomaticDirectories({ capability, signal } = {}) {
215
+ const capabilities = (capability ? [capability] : Object.values(cap.CAPABILITIES)).filter(c => models.functionPolicy(assignments()[c], c).mode === 'auto');
216
+ const selected = providers().filter(provider => models.providerModels(provider).some(model => model.capabilities.some(c => capabilities.includes(c))));
217
+ return Promise.all(selected.map(provider => refreshModelDirectory(provider, { signal,
218
+ save: directory => saveModelDirectory(provider.id, directory) })));
176
219
  }
177
220
 
178
221
  export function providerById(id) {
@@ -183,7 +226,7 @@ export function providerById(id) {
183
226
  const FIELD_OF = models.CAP_FIELD;
184
227
 
185
228
  /**
186
- * 严格选择:仅从功能分配名单取第一个可用模型。
229
+ * 按当前功能策略取第一个可用模型。
187
230
  * 返回该模型所属 provider 的副本,并把该能力的模型字段设为选中模型。
188
231
  * 向后兼容:调用方继续读 provider.apiKey / provider.imageModel 等。
189
232
  * 无可用模型 → null(不再兜底到任意 provider)。
@@ -194,12 +237,12 @@ export function pickFor(capability) {
194
237
 
195
238
  /**
196
239
  * 同一能力的有序 provider 列表(供 failover 依次尝试):
197
- * 只使用已分配顺序,不补入全局池。
240
+ * 自动池按登记顺序选择;手选名单不补池,停用没有候选。
198
241
  * 一个 provider 若池里有多个该能力模型,会返回多条(各自带不同模型)。
199
242
  */
200
- export function pickAllFor(capability, { includeRateLimited = false } = {}) {
201
- const pool = models.modelPool(providers(), { includeHostVision: true });
202
- const chosen = models.orderedModels(pool, capability, assignments()[capability], { includeRateLimited });
243
+ export function pickAllFor(capability, { includeRateLimited = false, includeUnavailable = false } = {}) {
244
+ const pool = models.functionModelPool(providers(), capability, assignments()[capability], { includeHostVision: true });
245
+ const chosen = models.orderedModels(pool, capability, assignments()[capability], { includeRateLimited, includeUnavailable });
203
246
  const out = [];
204
247
  for (const m of chosen) {
205
248
  if (m.ref === models.HOST_VISION_REF) {
@@ -226,8 +269,11 @@ export function assignmentOrder(capability) {
226
269
  const a = assignments()[capability];
227
270
  if (!a) return [];
228
271
  const pool = models.modelPool(allProviders(), { includeHostVision: true });
272
+ const policy = models.functionPolicy(a, capability);
273
+ if (policy.mode === 'auto') return models.orderedModels(pool, capability, a, { includeRateLimited: true, includeUnavailable: true }).map(model => model.ref);
274
+ if (policy.mode === 'disabled') return [];
229
275
  const refs = [];
230
- for (const raw of (Array.isArray(a) ? a : [a])) {
276
+ for (const raw of policy.models) {
231
277
  const m = resolveAssignment(pool, raw, capability);
232
278
  if (m && !refs.includes(m.ref)) refs.push(m.ref);
233
279
  else if (!m && typeof raw === 'string' && models.parseModelRef(raw) && !refs.includes(raw)) refs.push(raw);
@@ -284,6 +330,47 @@ export function clearAssignment(capability) {
284
330
  return true;
285
331
  }
286
332
 
333
+ /** Explicit policy changes preserve the previous manual list for a later switch. */
334
+ export function setFunctionPolicy(capability, mode, { hostVision } = {}) {
335
+ if (!cap.isKnownCapability(capability)) throw new TypeError('未知功能');
336
+ const c = load();
337
+ const previous = models.functionPolicy(c.assignments[capability], capability);
338
+ const next = { mode, models: models.savedAssignmentRefs(models.modelPool(c.providers, { includeHostVision: true }), capability, previous.models) };
339
+ if (capability === cap.CAPABILITIES.VISION) next.hostVision = hostVision === undefined ? previous.hostVision : hostVision;
340
+ models.functionPolicy(next, capability);
341
+ c.assignments[capability] = next;
342
+ persist();
343
+ return next;
344
+ }
345
+
346
+ /** One user confirmation updates capabilities and optional automatic use atomically. */
347
+ export function confirmModelUsage(id, modelId, capabilities, { enableAutomatic = false, musicProtocol } = {}) {
348
+ const c = load();
349
+ const provider = c.providers.find(item => item.id === id);
350
+ if (!provider) throw new TypeError('供应商不存在');
351
+ if (typeof modelId !== 'string' || !modelId.trim() || !Array.isArray(capabilities)
352
+ || !capabilities.every(cap.isKnownCapability) || typeof enableAutomatic !== 'boolean') throw new TypeError('请填写模型名称并确认已知功能');
353
+ if (musicProtocol !== undefined) normalizeMusicProtocol(musicProtocol);
354
+ const entries = (Array.isArray(provider.models) ? provider.models : models.providerModels(provider)).map(model => typeof model === 'string' ? { id: model } : { ...model });
355
+ let entry = entries.find(model => model.id === modelId.trim());
356
+ if (!entry) { entry = { id: modelId.trim() }; entries.push(entry); }
357
+ entry.capabilities = [...new Set(capabilities)];
358
+ entry.source = 'manual';
359
+ provider.models = entries;
360
+ if (musicProtocol !== undefined) {
361
+ if (entry.musicProtocol !== musicProtocol) clearModelCapabilityHealth(provider, entry.id, cap.CAPABILITIES.MUSIC);
362
+ entry.musicProtocol = musicProtocol;
363
+ }
364
+ if (enableAutomatic) for (const capability of entry.capabilities) {
365
+ const previous = models.functionPolicy(c.assignments[capability], capability);
366
+ c.assignments[capability] = { mode: 'auto', models: models.savedAssignmentRefs(models.modelPool(c.providers, { includeHostVision: true }), capability, previous.models),
367
+ ...(capability === cap.CAPABILITIES.VISION ? { hostVision: previous.hostVision } : {}) };
368
+ }
369
+ pruneHealthToEffectiveModels(provider);
370
+ persist();
371
+ return entry;
372
+ }
373
+
287
374
  /** 某 provider 的模型池覆盖的能力集合(合并各模型的能力标签) */
288
375
  export function capabilitiesOf(p) {
289
376
  const caps = new Set();
@@ -293,11 +380,6 @@ export function capabilitiesOf(p) {
293
380
  return [...caps];
294
381
  }
295
382
 
296
- const HEALTH_SENSITIVE_FIELDS = new Set([
297
- 'auth', 'type', 'baseUrl', 'mediaBaseUrl', 'apiKey', 'mediaProtocol',
298
- 'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel'
299
- ]);
300
-
301
383
  function healthContainer(provider) {
302
384
  const current = provider.health;
303
385
  if (current && current.version === 1 && Number.isSafeInteger(current.revision)
@@ -318,7 +400,7 @@ function safeHealthNote(provider, value) {
318
400
  const raw = String(value ?? '').trim();
319
401
  if (!raw) return '';
320
402
  let note = redactProviderMessage(raw, 200);
321
- const key = String(provider && provider.apiKey || '');
403
+ const key = String(provider && scopedProvider(provider).apiKey || '');
322
404
  if (key) note = note.split(key).join('[REDACTED]');
323
405
  return note;
324
406
  }
@@ -330,7 +412,9 @@ function observationEvidence(provider, result, fallbackSource) {
330
412
  return {
331
413
  at: new Date(Number.isFinite(atMs) ? atMs : Date.now()).toISOString(),
332
414
  source: ['probe', 'task'].includes(result && result.source) ? result.source : fallbackSource,
415
+ ...(result?.stage === 'poll' ? { stage: 'poll' } : {}),
333
416
  category: String(result && result.category || (result && result.ok ? 'success' : 'unknown')).slice(0, 40),
417
+ ...(typeof result?.providerCode === 'string' ? { providerCode: safeHealthNote(provider, result.providerCode) } : {}),
334
418
  ...(Number.isInteger(status) && status >= 100 && status <= 599 ? { httpStatus: status } : {}),
335
419
  ...(note ? { note } : {})
336
420
  };
@@ -340,6 +424,8 @@ function applyProviderHealth(provider, modelId, capability, result, fallbackSour
340
424
  const model = String(modelId || '').trim();
341
425
  if (!provider || !model || !cap.isKnownCapability(capability)) return false;
342
426
  const health = healthContainer(provider);
427
+ const scoped = scopedProvider(provider);
428
+ if (scoped !== provider) scoped.health = health;
343
429
  let observation = health.observations.find((item) => item && item.modelId === model
344
430
  && item.capability === capability);
345
431
  if (!observation) {
@@ -347,6 +433,12 @@ function applyProviderHealth(provider, modelId, capability, result, fallbackSour
347
433
  health.observations.push(observation);
348
434
  }
349
435
  const evidence = observationEvidence(provider, result || {}, fallbackSource);
436
+ const effective = { ...scopedProvider(provider), health };
437
+ if (recordModelAvailability(effective, model, capability, { ...result, ...evidence,
438
+ binding: result?.binding || modelAvailabilityBinding(effective, model, capability) }) && observation.availability?.state === 'blocked') {
439
+ const directory = currentModelDirectory(effective);
440
+ if (directory) directory.invalidatedAt = evidence.at;
441
+ }
350
442
  recordModelRateLimit(provider, model, { ...evidence, retryAfterMs: result?.retryAfterMs, until: result?.until, reason: result?.reason });
351
443
  if (result && result.ok === true) {
352
444
  observation.lastSuccess = evidence;
@@ -382,16 +474,6 @@ function pruneModelHealth(provider, modelId) {
382
474
  provider.health.observations = provider.health.observations.filter((item) => item.modelId !== modelId);
383
475
  }
384
476
 
385
- function resetHealthOnCriticalChange(previous, next, patch) {
386
- const changed = [...HEALTH_SENSITIVE_FIELDS].some((key) =>
387
- Object.prototype.hasOwnProperty.call(patch, key) && previous[key] !== next[key]);
388
- if (!changed) return;
389
- const current = previous.health;
390
- const revision = Number.isSafeInteger(current && current.revision) ? current.revision + 1 : 1;
391
- next.health = { version: 1, revision, observations: [],
392
- ...(current?.rateLimits?.length ? { rateLimits: current.rateLimits } : {}) };
393
- }
394
-
395
477
  /** 记录显式实测或真实任务观察;限流资格另存,保留原成功事实。 */
396
478
  export function recordProviderHealth(id, modelId, capability, result) {
397
479
  const c = load();
@@ -403,6 +485,14 @@ export function recordProviderHealth(id, modelId, capability, result) {
403
485
  return true;
404
486
  }
405
487
 
488
+ /** Adapter提交边界的可用性事件;poll不会调用这一入口。 */
489
+ export function recordAvailability({ providerId, modelId, capability, ...result }) {
490
+ const known = { image: cap.CAPABILITIES.IMAGE, video: cap.CAPABILITIES.VIDEO, music: cap.CAPABILITIES.MUSIC }[capability] || capability;
491
+ const current = runtimeProviders().find(provider => provider.id === providerId);
492
+ if (!current || result.binding && modelAvailabilityBinding(current, modelId, known) !== result.binding) return false;
493
+ return recordProviderHealth(providerId, modelId, known, result);
494
+ }
495
+
406
496
  /** DSH 调用边界记录冷却或明确耗尽;覆盖同账号该模型的所有能力。 */
407
497
  export function recordRateLimit({ providerId, modelId, capability, httpStatus = 429, category = 'rate_limit', at, until, retryAfterMs, reason }) {
408
498
  const known = { image: 'image-gen', video: 'video-gen' }[capability] || capability;
@@ -421,6 +511,10 @@ export function modelHealth(id, modelId, capability, { now = Date.now() } = {})
421
511
  source: blocked.source, category: blocked.category, httpStatus: blocked.httpStatus,
422
512
  ...(blocked.until ? { retryAt: blocked.until } : { reason: blocked.reason }),
423
513
  note: blocked.until ? '429 限流冷却,暂时跳过' : '额度或预算耗尽后已停用,手动实测成功后恢复' };
514
+ const unavailable = modelUnavailable(scopedProvider(provider), modelId, capability, { allowUnresolvedConnections: true });
515
+ if (unavailable) return { status: 'failed', modelUnavailable: true, reason: 'model_not_found',
516
+ observedAt: unavailable.at, source: unavailable.source, httpStatus: 404, category: 'invalid_request',
517
+ note: modelUnavailableReason() };
424
518
  const observation = healthObservation(provider, modelId, capability);
425
519
  const status = healthObservationState(observation, { now });
426
520
  const evidence = status === 'verified'
@@ -441,12 +535,18 @@ export function modelHealth(id, modelId, capability, { now = Date.now() } = {})
441
535
  /** 当前有序 failover 路径的健康快照;只含脱敏标量,不触发网络。 */
442
536
  export function providerHealthSnapshot({ now = Date.now() } = {}) {
443
537
  const capabilities = {};
538
+ // A stored Host reference is configured without local endpoint/key fields.
539
+ // This snapshot reports configuration and past evidence, not live readiness.
540
+ const healthProviders = runtimeProviders().filter(provider => provider.enabled !== false
541
+ && (provider.dshProvider || isConfiguredProvider(provider)));
444
542
  for (const capability of Object.values(cap.CAPABILITIES)) {
445
- const candidates = pickAllFor(capability, { includeRateLimited: true }).map((provider) => {
446
- const modelId = String(provider[FIELD_OF[capability]] || '');
447
- const health = provider.id === models.HOST_VISION_REF ? { status: 'configured', source: 'configuration' }
448
- : modelHealth(provider.id, modelId, capability, { now });
449
- return { providerId: provider.id, modelId, ...health };
543
+ const pool = models.functionModelPool(healthProviders, capability, assignments()[capability], {
544
+ includeHostVision: true, allowUnresolvedConnections: true
545
+ });
546
+ const candidates = models.orderedModels(pool, capability, assignments()[capability], { includeRateLimited: true, includeUnavailable: true }).map((model) => {
547
+ const health = model.ref === models.HOST_VISION_REF ? { status: 'configured', source: 'configuration' }
548
+ : modelHealth(model.providerId, model.id, capability, { now });
549
+ return { providerId: model.providerId, modelId: model.id, ...health };
450
550
  });
451
551
  const status = aggregateHealthStates(candidates.map((item) => item.status));
452
552
  const newest = [...candidates]
@@ -480,6 +580,17 @@ export function upsert(provider) {
480
580
  const { id, ...input } = provider;
481
581
  // PATCH 语义:缺失字段和 undefined 都不覆盖旧值;null/空串仍是显式更新。
482
582
  const rest = Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
583
+ const existing = id && c.providers.find(item => item.id === id);
584
+ const reference = rest.dshProvider || existing?.dshProvider;
585
+ if (reference) {
586
+ if (!isPlainObject(reference) || typeof reference.provider !== 'string' || !reference.provider.trim()
587
+ || typeof reference.settingsNs !== 'string' || !reference.settingsNs.trim()
588
+ || Object.keys(reference).some(key => !['provider', 'settingsNs'].includes(key))) throw new Error('DSH 连接引用无效');
589
+ if (['apiKey', 'baseUrl'].some(key => rest[key] !== undefined || existing?.[key] !== undefined)) {
590
+ throw new Error('DSH 引用不保存密钥或普通 Base URL;请新建引用供应商');
591
+ }
592
+ rest.dshProvider = { provider: reference.provider.trim(), settingsNs: reference.settingsNs.trim() };
593
+ }
483
594
  if (rest.visionInput !== undefined) rest.visionInput = normalizeVisionInputLimits(rest.visionInput) || null;
484
595
  if (Array.isArray(rest.models)) rest.models = rest.models.map(model => typeof model === 'string' ? model : ({ ...model,
485
596
  ...(model?.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(model.imageProtocol) } : {}),
package/lib/doctor.js CHANGED
@@ -103,16 +103,23 @@ function inspectConfig(dataDir, checks) {
103
103
  let incomplete = 0;
104
104
  let enabled = 0;
105
105
  let models = 0;
106
+ let hostReferences = 0;
106
107
  for (const provider of providers) {
107
108
  if (!plain(provider)) { incomplete++; continue; }
108
109
  if (provider.enabled !== false) enabled++;
109
- if (provider.enabled !== false && (!providerMediaBaseUrl(provider)
110
+ const reference = provider.dshProvider;
111
+ const managed = plain(reference) && typeof reference.provider === 'string' && reference.provider.trim()
112
+ && typeof reference.settingsNs === 'string' && reference.settingsNs.trim();
113
+ if (provider.enabled !== false && managed) hostReferences++;
114
+ if (provider.enabled !== false && !managed && (!providerMediaBaseUrl(provider)
110
115
  || (provider.auth !== 'none' && !providerApiKey(provider).trim()))) incomplete++;
111
116
  models += Array.isArray(provider.models) ? provider.models.length : 0;
112
117
  }
113
118
  if (incomplete) checks.push(result('providers', 'warn', `${incomplete} 个启用供应商缺少端点、所需凭据或结构无效(auth:none 不要求密钥)`, '在 Iris 工作台补全或停用对应供应商。'));
114
119
  else if (!enabled) checks.push(result('providers', 'warn', '没有启用的供应商', '在 Iris 工作台启用至少一个供应商。'));
115
120
  else checks.push(result('providers', 'ok', `${enabled} 个供应商已启用;凭据未输出`));
121
+ if (hostReferences) checks.push(result('provider-references', 'warn', `${hostReferences} 个 DSH 连接引用需由运行中宿主解析;离线未验证连接`,
122
+ '在 DSH 的 Iris 工作台检查原连接与功能池候选,无需在 Iris 重填宿主密钥。'));
116
123
  const assignments = config.value.assignments || {};
117
124
  const assignmentCount = Object.keys(assignments).length;
118
125
  if (!models) checks.push(result('models', 'warn', '模型池为空或尚未发现模型', '在工作台对供应商执行“发现模型”,再核对能力标签。'));
@@ -378,7 +385,7 @@ export function hostDoctor(host, evidence = {}, options = {}) {
378
385
  checks.push(result('dsh-version', 'ok', 'DSH ' + snapshot.host.version + ' 位于已验证支持范围'));
379
386
  } else {
380
387
  checks.push(result('dsh-version', 'error', 'DSH ' + snapshot.host.version + ' 超出已验证支持范围',
381
- '当前支持 DSH ' + SUPPORTED_DSH_VERSIONS.join('、') + ';旧 DSH 0.1.2 线或 0.1.5-rc.1 使用 Iris @mokuyoaxis/dsh-iris@0.1.4。其他版本须另行验收。'));
388
+ '当前支持 DSH ' + SUPPORTED_DSH_VERSIONS.join('、') + ';请核对所装 Iris 的兼容矩阵及 Node 要求,其他版本须另行验收。'));
382
389
  }
383
390
  }
384
391
 
@@ -1,7 +1,13 @@
1
1
  'use strict';
2
2
 
3
3
  /** 已完成宿主接口验收的版本;manifest/Doctor/验收脚本共用同一边界。 */
4
- export const SUPPORTED_DSH_VERSIONS = Object.freeze(['0.2.0-rc.2', '0.2.1-alpha.4']);
4
+ export const SUPPORTED_DSH_VERSIONS = Object.freeze([
5
+ '0.1.2-rc.1',
6
+ '0.1.5-rc.1', '0.1.5-rc.2', '0.1.5-rc.3',
7
+ '0.1.7-rc.1', '0.1.7-rc.2',
8
+ '0.2.0-rc.1', '0.2.0-rc.2',
9
+ '0.2.1-alpha.1', '0.2.1-alpha.2', '0.2.1-alpha.3', '0.2.1-alpha.4'
10
+ ]);
5
11
  export const DSH_SUPPORT_RANGE = SUPPORTED_DSH_VERSIONS.join(' || ');
6
12
 
7
13
  export function isSupportedDshVersion(value) {
@@ -2,7 +2,8 @@
2
2
 
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { allProviders, assignments, irisHome, recordRateLimit } from './config.js';
5
+ import { runtimeProviders, assignments, irisHome, recordRateLimit, recordAvailability, refreshAutomaticDirectories } from './config.js';
6
+ import { withDshProviderConfig } from './dsh-provider-config.js';
6
7
  import { createCommandService } from './command-service.js';
7
8
  import { createCoreArtifact, inspectCoreArtifact, listCoreArtifacts, listCoreArtifactMetadata, readCoreArtifactBytes } from './core-artifacts.js';
8
9
  import { createCoreRuntime } from './core-runtime.js';
@@ -23,9 +24,16 @@ const providerWatchers = new Map();
23
24
  const DEFAULT_POLL_INTERVAL_MS = 2500;
24
25
  const DEFAULT_MAX_WATCH_MS = 20 * 60 * 1000;
25
26
  const POLL_ERROR_TOLERANCE = 5;
27
+ let providerHost = () => undefined;
28
+
29
+ /** The Host supplies a fresh optional connection port; no credentials are retained here. */
30
+ export function configureProviderHostForDsh(factory) {
31
+ providerHost = factory;
32
+ return () => { if (providerHost === factory) providerHost = () => undefined; };
33
+ }
26
34
 
27
35
  function createConfiguredProviderAdapter(provider, options = {}) {
28
- return createProviderAdapter(provider, { ...options, onRateLimit: recordRateLimit });
36
+ return createProviderAdapter(provider, { ...options, onRateLimit: recordRateLimit, onModelAvailability: recordAvailability });
29
37
  }
30
38
 
31
39
  export class DshCoreAdapterError extends Error {
@@ -290,8 +298,9 @@ export function retryProviderTaskForDsh(input = {}) {
290
298
  }
291
299
  return withWriter(async (runtime) => {
292
300
  const commands = createCommandService(runtime, {
293
- resolveTaskCandidates: ({ capability, modelRef }) => {
294
- const catalog = { providers: allProviders(), assignments: assignments() };
301
+ resolveTaskCandidates: ({ capability, modelRef }) => withDshProviderConfig(providerHost(), async () => {
302
+ if (!modelRef) await refreshAutomaticDirectories({ capability: { image: 'image-gen', video: 'video-gen', music: 'music-gen' }[capability] || capability, signal: input.signal });
303
+ const catalog = { providers: runtimeProviders(), assignments: assignments() };
295
304
  const ref = modelRef || undefined;
296
305
  const candidates = capability === 'video'
297
306
  ? videoCandidatesFromCatalog(catalog, ref)
@@ -308,7 +317,7 @@ export function retryProviderTaskForDsh(input = {}) {
308
317
  selectionReason: route.selectionReason,
309
318
  providerBinding: providerTaskBinding(route.provider)
310
319
  }));
311
- }
320
+ }, { signal: input.signal, fresh: true })
312
321
  });
313
322
  const result = await commands.execute('task.retry', {
314
323
  task_id: taskId,
@@ -346,9 +355,11 @@ function observableProviderTask(task) {
346
355
  && task.phase !== 'terminal');
347
356
  }
348
357
 
349
- function configuredAdapterForTask(task) {
350
- const provider = providerForTaskFromCatalog({ providers: allProviders() }, task);
351
- return createConfiguredProviderAdapter(provider, { modelId: parseModelRef(task.modelRef)?.modelId });
358
+ async function configuredAdapterForTask(task) {
359
+ return withDshProviderConfig(providerHost(), () => {
360
+ const provider = providerForTaskFromCatalog({ providers: runtimeProviders() }, task);
361
+ return createConfiguredProviderAdapter(provider, { modelId: parseModelRef(task.modelRef)?.modelId });
362
+ }, { fresh: true });
352
363
  }
353
364
 
354
365
  function stopProviderWatch(taskId, expectedState) {
@@ -371,7 +382,7 @@ export async function watchProviderTaskForDsh(input = {}) {
371
382
  if (providerWatchers.has(taskId)) return false;
372
383
  const initial = await inspectProviderTaskForDsh(taskId, { signal: input.signal });
373
384
  if (!observableProviderTask(initial) || providerWatchers.has(taskId)) return false;
374
- configuredAdapterForTask(initial); // binding/模型漂移在定时器和网络前失败
385
+ await configuredAdapterForTask(initial); // binding/模型漂移在定时器和网络前失败
375
386
 
376
387
  const state = {
377
388
  timer: null,
@@ -400,7 +411,7 @@ export async function watchProviderTaskForDsh(input = {}) {
400
411
  }
401
412
  let adapter;
402
413
  try {
403
- adapter = configuredAdapterForTask(before);
414
+ adapter = await configuredAdapterForTask(before);
404
415
  } catch (error) {
405
416
  console.warn('[iris] Core Task 无法按原 Provider binding 恢复观察:' + taskId
406
417
  + ' · ' + String(error?.code || error?.message || error));
@@ -450,7 +461,7 @@ export async function resumeProviderTaskWatchesForDsh(options = {}) {
450
461
  for (const original of records) {
451
462
  if (!WATCHABLE_CAPABILITIES.has(original.capability)) continue;
452
463
  try {
453
- if (observableProviderTask(original)) configuredAdapterForTask(original);
464
+ if (observableProviderTask(original)) await configuredAdapterForTask(original);
454
465
  const needsRecovery = (original.phase === 'submitting' && original.acceptance === 'none')
455
466
  || original.cancelState === 'requested'
456
467
  || (original.outcome === 'succeeded' && ['pending', 'downloading'].includes(original.deliveryState))
@@ -5,6 +5,7 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { defineHostAdapter } from './host-contract.js';
7
7
  import { BrowserHtmlRenderer } from './render.js';
8
+ import { createDshProviderSource } from './dsh-provider-source.js';
8
9
 
9
10
  export function detectDshVersion({ entry = process.argv[1] } = {}) {
10
11
  if (!entry) return 'unknown';
@@ -93,37 +94,30 @@ function visionRouteError(message) {
93
94
  return Object.assign(new Error(message), { code: 'IRIS_HOST_CAPABILITY_INCOMPATIBLE' });
94
95
  }
95
96
 
96
- function dshVisionPort(llm, { defaults, requiresRoute }) {
97
+ function dshVisionPort(llm, defaults) {
97
98
  return Object.freeze({
98
99
  id: 'dsh-global',
99
100
  model: '全局视觉模型',
100
101
  async analyze({ question, ref, signal }) {
101
- let route = {};
102
- if (requiresRoute) {
103
- // rc.2 路由检查前就停止取消请求;旧桥接继续由宿主处理 signal,
104
- // 其 fallback 语义待共享调用控制迁移时统一。
105
- if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('已取消');
106
- const selection = defaults.currentSelection();
107
- if (typeof selection?.provider !== 'string' || !selection.provider.trim()
108
- || typeof selection?.model !== 'string' || !selection.model.trim()) {
109
- throw visionRouteError('DSH 默认模型没有明确路由;请在 Iris 配置视觉模型');
110
- }
111
- // 默认文本选择只有明确声明 image 输入时才可成为视觉候选。
112
- const { provider, model } = selection;
113
- const info = await llm.resolveModelInfo(provider, model, signal);
114
- if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('已取消');
115
- if (info?.provider !== provider || info?.id !== model || !Array.isArray(info.inputModalities)
116
- || !info.inputModalities.includes('image')) {
117
- throw visionRouteError('DSH 默认模型未确认支持图片;请在 Iris 配置视觉模型或选择已声明图片输入的宿主模型');
118
- }
119
- route = { provider, model };
102
+ if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('已取消');
103
+ const selection = defaults.currentSelection();
104
+ if (typeof selection?.provider !== 'string' || !selection.provider.trim()
105
+ || typeof selection?.model !== 'string' || !selection.model.trim()) {
106
+ throw visionRouteError('DSH 默认模型没有明确路由;请在 Iris 配置视觉模型');
107
+ }
108
+ // 默认文本选择只有明确声明 image 输入时才可成为视觉候选。
109
+ const { provider, model } = selection;
110
+ const info = await llm.resolveModelInfo(provider, model, signal);
111
+ if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('已取消');
112
+ if (info?.provider !== provider || info?.id !== model || !Array.isArray(info.inputModalities)
113
+ || !info.inputModalities.includes('image')) {
114
+ throw visionRouteError('DSH 默认模型未确认支持图片;请在 Iris 配置视觉模型或选择已声明图片输入的宿主模型');
120
115
  }
121
116
  const chunks = [];
122
117
  for await (const chunk of llm.stream({
123
118
  sessionId: undefined,
124
- provider: undefined,
125
- model: undefined,
126
- ...route,
119
+ provider,
120
+ model,
127
121
  signal,
128
122
  messages: [{
129
123
  role: 'user',
@@ -134,8 +128,8 @@ function dshVisionPort(llm, { defaults, requiresRoute }) {
134
128
  }]
135
129
  })) {
136
130
  if (chunk?.type === 'error') throw visionRouteError('DSH 视觉调用失败;请检查宿主模型状态');
137
- // rc.2 的 Runtime 把 Adapter 异常转换为 finish,而不是单独的 error 事件。
138
- if (requiresRoute && chunk?.type === 'finish') {
131
+ // Runtime 也会把 Adapter 异常转换为 finish,不能接受失败前的部分文本。
132
+ if (chunk?.type === 'finish') {
139
133
  if (chunk.reason?.kind === 'error') throw visionRouteError('DSH 视觉调用失败;请检查宿主模型状态');
140
134
  if (chunk.reason?.kind === 'aborted') throw visionRouteError('DSH 视觉调用已取消');
141
135
  }
@@ -208,6 +202,15 @@ export function createDshHostAdapter(ctx, { version = detectDshVersion() } = {})
208
202
  } else unavailable.skills = unavailableFor(skills, 'DSH Skill registry 需要 register');
209
203
 
210
204
  const llm = service(ctx, 'llm');
205
+ const settings = service(ctx, 'settings');
206
+ if (typeof llm?.listProviders === 'function' && typeof llm?.listConfigurableProviders === 'function'
207
+ && typeof settings?.describe === 'function') {
208
+ ports.providerConnections = createDshProviderSource(() => ({
209
+ llm: service(ctx, 'llm'), settings: service(ctx, 'settings'), credentials: service(ctx, 'credentials')
210
+ }));
211
+ } else unavailable.providerConnections = {
212
+ kind: 'unavailable', reason: '当前 DSH 未提供供应商连接目录与设置接口,可继续手动添加供应商'
213
+ };
211
214
  const defaults = service(ctx, 'agentDefaultModel');
212
215
  if (llm && typeof llm.stream === 'function') {
213
216
  ports.textModel = Object.freeze({
@@ -219,12 +222,12 @@ export function createDshHostAdapter(ctx, { version = detectDshVersion() } = {})
219
222
  ? () => defaults.currentSelection()
220
223
  : () => null
221
224
  });
222
- const requiresRoute = /^0\.2\./.test(version);
223
- if (!requiresRoute || defaults && typeof defaults.currentSelection === 'function'
225
+ // 旧 SDK 同样提供精确路由;按接口探测,避免用宿主版本号误选历史路径。
226
+ if (defaults && typeof defaults.currentSelection === 'function'
224
227
  && typeof llm.resolveModelInfo === 'function') {
225
- ports.visionModel = dshVisionPort(llm, { defaults, requiresRoute });
228
+ ports.visionModel = dshVisionPort(llm, defaults);
226
229
  } else unavailable.visionModel = {
227
- kind: 'unavailable', reason: 'DSH 0.2 视觉桥接需要默认模型选择和精确图片能力元数据'
230
+ kind: 'unavailable', reason: 'DSH 视觉桥接需要默认模型选择和精确图片能力元数据'
228
231
  };
229
232
  } else {
230
233
  unavailable.textModel = unavailableFor(llm, 'DSH llm 需要 stream');
@@ -0,0 +1,35 @@
1
+ import { allProviders } from './config.js';
2
+ import { hasProviderConfigScope, withProviderConfigScope } from './provider-config-scope.js';
3
+ import { DshProviderSourceError } from './dsh-provider-source.js';
4
+
5
+ export function normalizeDshProvider(value) {
6
+ if (!value || typeof value !== 'object' || Array.isArray(value)
7
+ || typeof value.provider !== 'string' || !value.provider.trim()
8
+ || typeof value.settingsNs !== 'string' || !value.settingsNs.trim()
9
+ || Object.keys(value).some(key => !['provider', 'settingsNs'].includes(key))) {
10
+ throw Object.assign(new Error('DSH 连接引用需要 provider 和 settingsNs'), { code: 'IRIS_CONFIG_INPUT_INVALID' });
11
+ }
12
+ return { provider: value.provider.trim(), settingsNs: value.settingsNs.trim() };
13
+ }
14
+
15
+ /** Existing request handlers see resolved copies; the persistent store stays raw. */
16
+ export async function withDshProviderConfig(host, callback, { signal, fresh = false } = {}) {
17
+ if (!fresh && hasProviderConfigScope()) return callback();
18
+ const providers = allProviders();
19
+ if (!providers.some(provider => provider.dshProvider)) return callback();
20
+ const resolved = await Promise.all(providers.map(async provider => {
21
+ if (!provider.dshProvider || provider.enabled === false) return provider;
22
+ try {
23
+ if (typeof host?.ports?.providerConnections?.resolve !== 'function') {
24
+ throw new DshProviderSourceError('当前宿主无法解析 DSH 连接,请检查原连接或改用手动供应商');
25
+ }
26
+ const value = await host.ports.providerConnections.resolve(provider.dshProvider, { signal });
27
+ return { ...provider, baseUrl: value.baseUrl, apiKey: value.apiKey };
28
+ } catch (error) {
29
+ if (!(error instanceof DshProviderSourceError)) throw error;
30
+ return { ...provider, enabled: false, connectionError: error.message };
31
+ }
32
+ }));
33
+ signal?.throwIfAborted();
34
+ return withProviderConfigScope(resolved, callback);
35
+ }