@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
@@ -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', '模型池为空或尚未发现模型', '在工作台对供应商执行“发现模型”,再核对能力标签。'));
@@ -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';
@@ -201,6 +202,15 @@ export function createDshHostAdapter(ctx, { version = detectDshVersion() } = {})
201
202
  } else unavailable.skills = unavailableFor(skills, 'DSH Skill registry 需要 register');
202
203
 
203
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
+ };
204
214
  const defaults = service(ctx, 'agentDefaultModel');
205
215
  if (llm && typeof llm.stream === 'function') {
206
216
  ports.textModel = Object.freeze({
@@ -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
+ }
@@ -0,0 +1,102 @@
1
+ /** Read only public DSH services; never open profile or credential files. */
2
+ export class DshProviderSourceError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = 'DshProviderSourceError';
6
+ this.code = 'IRIS_DSH_PROVIDER_UNAVAILABLE';
7
+ }
8
+ }
9
+
10
+ const unavailable = message => { throw new DshProviderSourceError(message); };
11
+ const refName = value => typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
12
+ const recordId = value => typeof value === 'string' && /^[a-z][a-z0-9-]*$/.test(value);
13
+
14
+ async function credentialRead(read) {
15
+ try { return await read(); }
16
+ catch (_) { unavailable('DSH 凭据服务读取失败,请在宿主检查该连接'); }
17
+ }
18
+
19
+ function endpoint(value) {
20
+ let url;
21
+ try { url = new URL(value); } catch (_) { unavailable('DSH 连接没有可复用的 HTTP(S) 地址'); }
22
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
23
+ unavailable('DSH 连接地址包含不支持的协议或凭据参数');
24
+ }
25
+ return url.href.replace(/\/+$/, '');
26
+ }
27
+
28
+ export function createDshProviderSource(services) {
29
+ function entries() {
30
+ const { llm, settings, credentials } = services();
31
+ if (typeof llm?.listProviders !== 'function' || typeof llm?.listConfigurableProviders !== 'function'
32
+ || typeof settings?.describe !== 'function') {
33
+ unavailable('当前 DSH 未提供连接目录与设置接口,请手动添加 Iris 供应商');
34
+ }
35
+ const active = new Set(llm.listProviders().map(provider => provider.id));
36
+ const forms = settings.describe({ redactSecrets: true });
37
+ return llm.listConfigurableProviders().filter(entry => active.has(entry.provider)).map(entry => {
38
+ let profile = forms.find(form => form.ns === entry.settingsNs)?.value;
39
+ for (const key of entry.settingsPath) profile = profile && Object.hasOwn(profile, key) ? profile[key] : undefined;
40
+ return { entry, profile, credentials };
41
+ });
42
+ }
43
+
44
+ async function facts({ entry, profile, credentials }) {
45
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) unavailable('DSH 连接设置不可读取');
46
+ if (profile.headers && Object.keys(profile.headers).length) unavailable('该 DSH 连接含自定义请求头,当前请手动配置 Iris 供应商');
47
+ const baseUrl = endpoint(profile.baseURL || entry.defaultBaseURL);
48
+ if (!credentials) unavailable('当前 DSH 没有凭据解析服务,请手动配置 Iris 供应商');
49
+ if (profile.apiKeyEnv !== undefined) {
50
+ if (!refName(profile.apiKeyEnv) || typeof credentials.describe !== 'function'
51
+ || typeof credentials.resolve !== 'function') unavailable('DSH 凭据引用不可用');
52
+ if (!(await credentialRead(() => credentials.describe(profile.apiKeyEnv))).configured) unavailable('DSH 连接尚未配置可用密钥');
53
+ return { baseUrl, ref: profile.apiKeyEnv };
54
+ }
55
+ // This is the documented api-key record space owned by llm-pi-ai.
56
+ if (entry.settingsNs === 'llm-pi-ai' && recordId(entry.provider)
57
+ && typeof credentials.describeRecord === 'function' && typeof credentials.readRecord === 'function') {
58
+ const key = 'llm-pi-ai/' + entry.provider;
59
+ const info = await credentialRead(() => credentials.describeRecord(key));
60
+ if (info.configured) {
61
+ if (info.kind !== 'api-key') unavailable('该 DSH 连接使用登录授权,当前不支持将其用于媒体请求');
62
+ return { baseUrl, recordKey: key };
63
+ }
64
+ }
65
+ for (const ref of entry.nativeAuth?.env || []) {
66
+ if (refName(ref) && typeof credentials.describe === 'function' && typeof credentials.resolve === 'function'
67
+ && (await credentialRead(() => credentials.describe(ref))).configured) return { baseUrl, ref };
68
+ }
69
+ unavailable('该 DSH 连接没有可复用的 API Key;登录授权和专有认证需单独适配');
70
+ }
71
+
72
+ async function list() {
73
+ return Promise.all(entries().map(async source => {
74
+ const { entry } = source;
75
+ const common = { provider: entry.provider, settingsNs: entry.settingsNs, name: entry.displayName || entry.provider };
76
+ try { return { ...common, baseUrl: (await facts(source)).baseUrl, available: true }; }
77
+ catch (error) {
78
+ if (!(error instanceof DshProviderSourceError)) throw error;
79
+ return { ...common, available: false, reason: error.message };
80
+ }
81
+ }));
82
+ }
83
+
84
+ async function resolve(reference, { signal } = {}) {
85
+ signal?.throwIfAborted();
86
+ const source = entries().find(({ entry }) => entry.provider === reference.provider && entry.settingsNs === reference.settingsNs);
87
+ if (!source) unavailable('DSH 原连接已停用、移除或改变来源,请检查宿主供应商设置');
88
+ const plan = await facts(source);
89
+ let key;
90
+ if (plan.ref) key = (await credentialRead(() => source.credentials.resolve(plan.ref)))?.value;
91
+ else {
92
+ const record = await credentialRead(() => source.credentials.readRecord(plan.recordKey));
93
+ if (record?.kind === 'api-key') key = record.key;
94
+ }
95
+ signal?.throwIfAborted();
96
+ if (typeof key !== 'string' || !key.trim() || /[\r\n]/.test(key)) unavailable('DSH 原连接没有可用于媒体请求的 API Key');
97
+ // Even accidental JSON serialization of this Host-only result excludes the key.
98
+ return Object.freeze(Object.defineProperty({ baseUrl: plan.baseUrl }, 'apiKey', { value: key }));
99
+ }
100
+
101
+ return Object.freeze({ list, resolve });
102
+ }