@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/models.js CHANGED
@@ -13,6 +13,8 @@
13
13
  */
14
14
  import { CAPABILITIES, configuredModelCapabilities } from './capability.js';
15
15
  import { modelRateLimit } from './provider-health.js';
16
+ import { supportsModelCapability } from './provider-protocol.js';
17
+ import { currentModelDirectory, modelDirectoryEntry, modelDirectoryStatus, modelUnavailable } from './model-directory.js';
16
18
 
17
19
  /** 能力 → provider 上的模型字段(保持向后兼容的返回形状) */
18
20
  export const CAP_FIELD = {
@@ -108,7 +110,11 @@ export function providerModels(p) {
108
110
  const push = (id, caps, extra) => {
109
111
  const name = String(id || '').trim();
110
112
  if (!name || out.some((m) => m.id === name)) return;
113
+ const unavailableCapabilities = caps.filter(capability => modelUnavailable(p, name, capability, { allowUnresolvedConnections: true }));
114
+ const catalogStatus = modelDirectoryStatus(p, name);
111
115
  out.push({ id: name, providerId: p.id, ref: modelRef(p.id, name), capabilities: [...caps], ...(extra || {}),
116
+ ...(catalogStatus !== 'unknown' ? { catalogStatus } : {}),
117
+ ...(unavailableCapabilities.length ? { unavailableCapabilities } : {}),
112
118
  ...(modelRateLimit(p, name) ? { rateLimited: true } : {}) });
113
119
  };
114
120
  // ① 显式 models 数组(带可选 capabilities 覆盖 + verified/source 透传)
@@ -119,7 +125,8 @@ export function providerModels(p) {
119
125
  for (const m of p.models) {
120
126
  const id = typeof m === 'string' ? m : m && m.id;
121
127
  // 没有明确能力的名称保留为未标注模型,不进入候选链。
122
- const caps = configuredModelCapabilities(p, m);
128
+ const metadata = m?.source === 'discovered' && modelDirectoryEntry(p, id, { allowUnresolvedConnections: true });
129
+ const caps = metadata ? metadata.capabilities : configuredModelCapabilities(p, m);
123
130
  const extra = typeof m === 'object' && m ? { verified: m.verified, source: m.source,
124
131
  ...(m.imageProtocol !== undefined ? { imageProtocol: m.imageProtocol } : {}),
125
132
  ...(m.musicProtocol !== undefined ? { musicProtocol: m.musicProtocol } : {}),
@@ -136,6 +143,14 @@ export function providerModels(p) {
136
143
  return out;
137
144
  }
138
145
 
146
+ /** 管理界面附上新目录条目,未登记的发现项不会因此进入执行范围。 */
147
+ export function providerModelsWithDirectory(provider) {
148
+ const configured = providerModels(provider);
149
+ const directory = currentModelDirectory(provider, { allowUnresolvedConnections: true });
150
+ return configured.concat((directory?.models || []).filter(model => !configured.some(entry => entry.id === model.id))
151
+ .map(model => ({ ...model, providerId: provider.id, ref: modelRef(provider.id, model.id), source: 'discovered', catalogStatus: 'listed', directoryOnly: true })));
152
+ }
153
+
139
154
  /** 只解析用户配置中的模型;裸 ID 与复合引用共享能力校验。 */
140
155
  export function resolveModelRef(provider, capability, requested = '') {
141
156
  const pool = providerModels(provider).filter((m) => m.capabilities.includes(capability));
@@ -162,19 +177,67 @@ export function resolvePoolModel(pool, raw, capability) {
162
177
  && m.capabilities.includes(capability)) || null;
163
178
  }
164
179
 
165
- /** DSH 与 CLI 共用的执行名单:只按明确分配排序并去重。 */
166
- export function orderedModels(pool, capability, assignments, { includeRateLimited = false } = {}) {
180
+ /** Legacy assignments stay manual/disabled; only an explicit policy enables a pool. */
181
+ export function functionPolicy(value, capability) {
182
+ if (value && typeof value === 'object' && !Array.isArray(value) && Object.hasOwn(value, 'mode')) {
183
+ if (!['auto', 'manual', 'disabled'].includes(value.mode)
184
+ || Object.keys(value).some(key => !['mode', 'models', 'hostVision'].includes(key))
185
+ || (value.models !== undefined && (!Array.isArray(value.models) || !value.models.every(ref => typeof ref === 'string')))
186
+ || (value.hostVision !== undefined && (typeof value.hostVision !== 'boolean'
187
+ || (value.hostVision && capability !== CAPABILITIES.VISION)))) {
188
+ throw Object.assign(new TypeError('功能策略需要 auto、manual 或 disabled;models 为引用数组,hostVision 仅用于视觉'), {
189
+ code: 'IRIS_FUNCTION_POLICY_INVALID'
190
+ });
191
+ }
192
+ return { mode: value.mode, models: value.models || [], hostVision: value.hostVision === true };
193
+ }
194
+ return { mode: value ? 'manual' : 'disabled', models: Array.isArray(value) ? value : value ? [value] : [], hostVision: false };
195
+ }
196
+
197
+ /** Preserve legacy names/object references when a user explicitly changes policy. */
198
+ export function savedAssignmentRefs(pool, capability, values) {
199
+ const refs = values.map(raw => {
200
+ const resolved = resolvePoolModel(pool, raw, capability);
201
+ if (resolved) return resolved.ref;
202
+ if (typeof raw === 'string') return raw;
203
+ const id = String(raw?.modelId || raw?.id || '');
204
+ return id && raw?.providerId ? modelRef(raw.providerId, id) : id;
205
+ });
206
+ return [...new Set(refs.filter(Boolean))];
207
+ }
208
+
209
+ /** Automatic pools reject known protocol gaps before constructing any adapter. */
210
+ export function functionModelPool(providers, capability, assignment, { includeHostVision = false, allowUnresolvedConnections = false } = {}) {
211
+ const policy = functionPolicy(assignment, capability);
212
+ return modelPool(providers, { includeHostVision }).filter(model => {
213
+ if (!model.capabilities.includes(capability)) return false;
214
+ if (policy.mode !== 'auto' || model.ref === HOST_VISION_REF) return true;
215
+ const provider = providers.find(provider => provider.id === model.providerId);
216
+ return supportsModelCapability(provider, model.id, capability, {
217
+ checkEndpoint: !allowUnresolvedConnections || !provider?.dshProvider
218
+ });
219
+ });
220
+ }
221
+
222
+ /** Shared scope and stable ordering; discovery alone never expands an automatic pool. */
223
+ export function orderedModels(pool, capability, assignments, { includeRateLimited = false, includeUnavailable = false } = {}) {
167
224
  const ordered = [];
168
225
  const seen = new Set();
169
226
  const add = (model) => {
170
227
  if (model && (includeRateLimited || !model.rateLimited)
228
+ && (includeUnavailable || !model.unavailableCapabilities?.includes(capability))
171
229
  && model.capabilities.includes(capability) && !seen.has(model.ref)) {
172
230
  seen.add(model.ref);
173
231
  ordered.push(model);
174
232
  }
175
233
  };
176
- for (const raw of (Array.isArray(assignments) ? assignments : (assignments ? [assignments] : []))) {
177
- add(resolvePoolModel(pool, raw, capability));
234
+ const policy = functionPolicy(assignments, capability);
235
+ if (policy.mode === 'auto') {
236
+ for (const model of pool) {
237
+ if (model.source !== 'discovered' && (model.ref !== HOST_VISION_REF || policy.hostVision)) add(model);
238
+ }
239
+ } else if (policy.mode === 'manual') {
240
+ for (const raw of policy.models) add(resolvePoolModel(pool, raw, capability));
178
241
  }
179
242
  return ordered;
180
243
  }
@@ -189,7 +252,9 @@ export function selectionReasonForModel(pool, capability, assignments, modelOrRe
189
252
  modelOrRef && typeof modelOrRef === 'object' ? modelOrRef.ref : modelOrRef,
190
253
  capability);
191
254
  if (!selected) return null;
192
- for (const raw of (Array.isArray(assignments) ? assignments : (assignments ? [assignments] : []))) {
255
+ const policy = functionPolicy(assignments, capability);
256
+ if (policy.mode === 'auto') return 'pool';
257
+ for (const raw of policy.models) {
193
258
  const assigned = resolvePoolModel(pool, raw, capability);
194
259
  if (assigned?.ref === selected.ref) return 'assignment';
195
260
  }
@@ -8,13 +8,13 @@ import { waitForModelWork } from './model-call-runtime.js';
8
8
  import { createVisionInputObserver } from './vision-image-input.js';
9
9
 
10
10
  export async function runOcrRequest(host, { providers = [], model, image, prepareImage, signal,
11
- chunkHeight, overlap, maxDimension, maxInvocations, budget = OCR_BUDGET, onRateLimit }) {
11
+ chunkHeight, overlap, maxDimension, maxInvocations, budget = OCR_BUDGET, onRateLimit, onModelAvailability }) {
12
12
  const settings = normalizeOcrSettings({ chunkHeight, overlap, maxDimension, maxInvocations });
13
13
  const operation = createModelOperation({ signal, budget: { timeoutMs: budget.timeoutMs, maxInvocations: settings.maxInvocations } });
14
14
  try {
15
15
  if (operation.signal.aborted) throw new ModelPortError('IRIS_MODEL_ABORTED');
16
16
  const inputs = new Map();
17
- const candidates = buildVisionModelCandidates(host, { providers, model, onRateLimit, prepareImages: true,
17
+ const candidates = buildVisionModelCandidates(host, { providers, model, onRateLimit, onModelAvailability, prepareImages: true,
18
18
  prepareImageRequest: createVisionInputObserver(inputs) });
19
19
  normalizeModelCall(modelPortSnapshot(candidates[0].port), { prompt: 'OCR',
20
20
  image: { bytes: new Uint8Array([0]), mediaType: 'image/png' } }, { budget });
@@ -10,8 +10,9 @@ import * as defaultTransport from './adapters.js';
10
10
  import { ProviderContractError, providerErrorRecord } from './provider-contract.js';
11
11
  import { writePrivateFile } from './private-storage.js';
12
12
  import { defineProviderAdapter } from './provider-adapter.js';
13
- import { selectMediaProtocol, providerApiKey, providerMediaBaseUrl, unsupportedProtocolError, supportsImageEdit } from './provider-protocol.js';
14
- import { modelRateLimit, reportModelRateLimit, isFreeQuotaExhausted } from './provider-health.js';
13
+ import { selectMediaProtocol, providerApiKey, providerMediaBaseUrl, unsupportedProtocolError, supportsImageEdit, dashscopeTtsInputIssue } from './provider-protocol.js';
14
+ import { modelRateLimit, reportModelRateLimit, isFreeQuotaExhausted, recordModelAvailability } from './provider-health.js';
15
+ import { modelUnavailable, modelUnavailableReason, modelAvailabilityBinding } from './model-directory.js';
15
16
  import { prepareVisionImage, visionInputLimits } from './vision-image-input.js';
16
17
 
17
18
  const AMBIGUOUS_4XX = new Set([408, 409, 425, 499]);
@@ -74,6 +75,15 @@ export function mapProviderAdapterError(error, context = {}) {
74
75
  });
75
76
  }
76
77
 
78
+ /** Remove the operation's exact key before generic redaction can truncate it. */
79
+ function mapProviderErrorWithKey(error, context, key) {
80
+ const scrub = value => typeof value === 'string' && key ? value.split(key).join('[REDACTED]') : value;
81
+ return providerErrorRecord(mapProviderAdapterError(error, context), {
82
+ safeMessage: scrub(String(error?.safeMessage || error?.message || error)),
83
+ providerCode: scrub(error?.providerCode ?? error?.code)
84
+ });
85
+ }
86
+
77
87
  function unsupportedCapability(capability, protocol) {
78
88
  return new ProviderContractError(
79
89
  '协议 ' + protocol + ' 不支持任务能力 ' + String(capability || ''),
@@ -174,6 +184,10 @@ function operationsForDashScope(provider, transport) {
174
184
  return { kind: 'accepted', remoteTaskId };
175
185
  }
176
186
  if (request.capability === 'tts') {
187
+ const issue = dashscopeTtsInputIssue(request.model, input.voice);
188
+ if (issue) throw new ProviderContractError(issue, {
189
+ stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
190
+ });
177
191
  const value = await transport.synthesizeTts({
178
192
  ...common, text: input.text, voice: input.voice
179
193
  });
@@ -197,9 +211,9 @@ function operationsForDashScope(provider, transport) {
197
211
  if (!result.ok) {
198
212
  return {
199
213
  kind: pollFailureKind(result.status),
200
- error: mapProviderAdapterError(result.message || '转写任务失败', {
214
+ error: mapProviderErrorWithKey(result.message || '转写任务失败', {
201
215
  stage: 'poll', acceptance: 'accepted'
202
- })
216
+ }, key)
203
217
  };
204
218
  }
205
219
  // E3:转写正文物化为 text/plain Artifact(inline-base64,不再依赖签名 URL 生命周期)。
@@ -216,9 +230,9 @@ function operationsForDashScope(provider, transport) {
216
230
  if (!result.ok) {
217
231
  return {
218
232
  kind: pollFailureKind(result.status),
219
- error: mapProviderAdapterError(result.message || '生成任务失败', {
233
+ error: mapProviderErrorWithKey(result.message || '生成任务失败', {
220
234
  stage: 'poll', acceptance: 'accepted'
221
- })
235
+ }, key)
222
236
  };
223
237
  }
224
238
  return {
@@ -253,7 +267,7 @@ function operationsForDashScope(provider, transport) {
253
267
  };
254
268
  },
255
269
 
256
- mapError: mapProviderAdapterError
270
+ mapError: (error, context) => mapProviderErrorWithKey(error, context, key)
257
271
  };
258
272
  }
259
273
 
@@ -336,7 +350,7 @@ function operationsForOpenAiImages(provider, transport, protocol = 'openai-image
336
350
  };
337
351
  },
338
352
 
339
- mapError: mapProviderAdapterError
353
+ mapError: (error, context) => mapProviderErrorWithKey(error, context, key)
340
354
  };
341
355
  }
342
356
 
@@ -397,7 +411,7 @@ export function createGeminiWebMusicProviderAdapter(provider, { transport = defa
397
411
  return { kind: 'completed', artifacts };
398
412
  },
399
413
  async download({ artifact, targetPath }) { return writeInlineArtifact(artifact, targetPath); },
400
- mapError: mapProviderAdapterError
414
+ mapError: (error, context) => mapProviderErrorWithKey(error, context, key)
401
415
  },
402
416
  unsupported: { poll: '网关 Music 没有客户端可恢复的远端任务ID', cancel: '网关 Music 不提供远端取消' },
403
417
  unsupportedPreparation: 'Music 描述输入不需要上传'
@@ -431,6 +445,23 @@ export function createConfiguredProviderAdapter(provider, options = {}) {
431
445
  retryAfterMs: error.retryAfterMs,
432
446
  source: options.allowRateLimited ? 'probe' : 'task'
433
447
  }, options.onRateLimit, request.capability);
448
+ const availability = result => {
449
+ if (name !== 'submit') return;
450
+ const event = { ok: result.ok === true, category: result.category,
451
+ httpStatus: result.httpStatus ?? result.status, providerCode: result.providerCode || result.code,
452
+ note: result.safeMessage || result.message,
453
+ stage: 'submit', source: options.allowRateLimited ? 'probe' : 'task',
454
+ binding: modelAvailabilityBinding(provider, modelId, capability) };
455
+ if (!recordModelAvailability(provider, modelId, request.capability, event)) return;
456
+ try { options.onModelAvailability?.({ providerId: provider.id, modelId, capability: request.capability, ...event }); }
457
+ catch (_) { console.warn('[iris] 模型可用性未能持久化,请检查配置写入权限'); }
458
+ };
459
+ const capability = { image: 'image-gen', video: 'video-gen', music: 'music-gen' }[request.capability] || request.capability;
460
+ if (name === 'submit' && !options.allowRateLimited && modelUnavailable(provider, modelId, capability)) {
461
+ throw new ProviderContractError(modelUnavailableReason(), {
462
+ stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
463
+ });
464
+ }
434
465
  if (name === 'submit' && !options.allowRateLimited && modelRateLimit(provider, modelId)) {
435
466
  const blocked = modelRateLimit(provider, modelId);
436
467
  throw new ProviderContractError(blocked.until ? '该模型正在限流冷却,请稍后使用' : '该模型额度或预算耗尽,请手动实测成功后再使用', {
@@ -440,9 +471,11 @@ export function createConfiguredProviderAdapter(provider, options = {}) {
440
471
  try {
441
472
  const result = await run(request);
442
473
  if (result.error) report(result.error);
474
+ availability(result.error || { ok: ['completed', 'accepted'].includes(result.kind) });
443
475
  return result;
444
476
  } catch (error) {
445
477
  report(error);
478
+ availability(error);
446
479
  throw error;
447
480
  }
448
481
  };
@@ -4,11 +4,12 @@ import crypto from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { CAPABILITIES } from './capability.js';
7
- import { modelPool, orderedModels, providerModels, parseModelRef, resolveModelRef, resolvePoolModel, selectionReasonForModel } from './models.js';
7
+ import { modelPool, functionModelPool, functionPolicy, orderedModels, providerModels, parseModelRef, resolveModelRef, resolvePoolModel, selectionReasonForModel } from './models.js';
8
8
  import { dashscopeApiBase, selectMediaProtocol, providerForImageModel, providerForMusicModel, providerMediaBaseUrl, isConfiguredProvider } from './provider-protocol.js';
9
9
  import { redactProviderMessage } from './provider-contract.js';
10
10
  import { modelRateLimit } from './provider-health.js';
11
11
  import { normalizeVisionInputLimits } from './vision-image-input.js';
12
+ import { modelDirectorySummary, modelUnavailableReason, validateModelDirectory } from './model-directory.js';
12
13
 
13
14
  export class ProviderCatalogError extends Error {
14
15
  constructor(code, message) {
@@ -49,6 +50,8 @@ export function loadProviderCatalog(configPath) {
49
50
  || Array.isArray(parsed.assignments)))) {
50
51
  fail('IRIS_PROVIDER_CONFIG_INVALID', 'provider-config 结构无效');
51
52
  }
53
+ for (const [capability, assignment] of Object.entries(parsed.assignments || {})) functionPolicy(assignment, capability);
54
+ for (const provider of parsed.providers) validateModelDirectory(provider.modelDirectory);
52
55
  return parsed;
53
56
  }
54
57
 
@@ -58,13 +61,13 @@ const CANDIDATE_CAP_LABEL = Object.freeze({
58
61
  [CAPABILITIES.MUSIC]: '音乐'
59
62
  });
60
63
 
61
- /** 与 DSH 相同的严格功能名单;CLI 不附加宿主资源。 */
64
+ /** 与 DSH 共用功能策略;CLI 不附加宿主资源。 */
62
65
  function candidatesFromCatalog(catalog, requestedModelRef, capability) {
63
66
  const label = CANDIDATE_CAP_LABEL[capability];
64
67
  if (!label) fail('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', '当前 Headless 候选链只开放图片、视频、语音与转写能力');
65
68
  const providers = catalog.providers.filter(isConfiguredProvider);
66
- const configuredPool = modelPool(providers).filter((item) => item.capabilities.includes(capability));
67
- const pool = orderedModels(configuredPool, capability, catalog.assignments?.[capability], { includeRateLimited: true });
69
+ const configuredPool = functionModelPool(providers, capability, catalog.assignments?.[capability]);
70
+ const pool = orderedModels(configuredPool, capability, catalog.assignments?.[capability], { includeRateLimited: true, includeUnavailable: true });
68
71
  const requested = String(requestedModelRef || '').trim();
69
72
  if (requested) {
70
73
  const parsed = parseModelRef(requested);
@@ -72,6 +75,7 @@ function candidatesFromCatalog(catalog, requestedModelRef, capability) {
72
75
  const exact = resolvePoolModel(pool, requested, capability);
73
76
  if (!exact) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `指定模型未分配给${label}功能、未启用或没有所需能力`);
74
77
  const provider = providers.find((item) => item.id === exact.providerId);
78
+ if (exact.unavailableCapabilities?.includes(capability)) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', modelUnavailableReason());
75
79
  if (exact.rateLimited) fail('IRIS_PROVIDER_MODEL_RATE_LIMITED', modelRateLimit(provider, exact.id)?.until
76
80
  ? '指定模型正在限流冷却,请稍后使用或手动实测'
77
81
  : '指定模型额度或预算耗尽,请手动实测成功后再使用');
@@ -81,8 +85,8 @@ function candidatesFromCatalog(catalog, requestedModelRef, capability) {
81
85
  })]);
82
86
  }
83
87
  const assignment = catalog.assignments?.[capability];
84
- const ordered = pool.filter(model => !model.rateLimited);
85
- if (!ordered.length) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `请先给${label}功能分配当前入口可用的模型`);
88
+ const ordered = pool.filter(model => !model.rateLimited && !model.unavailableCapabilities?.includes(capability));
89
+ if (!ordered.length) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `请检查${label}功能的启用方式、已确认模型、连接和协议;当前没有可用候选`);
86
90
  return Object.freeze(ordered.map((item) => Object.freeze({
87
91
  provider: providers.find((provider) => provider.id === item.providerId),
88
92
  model: item.id,
@@ -133,20 +137,21 @@ export function providerCatalogSnapshot(catalog) {
133
137
  mediaProtocol: safeLabel(selection.mediaProtocol), protocolInferred: selection.protocolInferred,
134
138
  ...(provider.visionInput ? { visionInput: normalizeVisionInputLimits(provider.visionInput) } : {}),
135
139
  capabilities: Object.values(CAPABILITIES).filter((capability) => models.some((model) => model.capabilities.includes(capability))),
136
- modelCount: models.length
140
+ modelCount: models.length, directory: modelDirectorySummary(provider)
137
141
  };
138
142
  })
139
143
  };
140
144
  }
141
145
 
142
146
  export function catalogCapabilitySnapshot(catalog) {
143
- const pool = modelPool(catalog.providers.filter(isConfiguredProvider));
144
147
  return {
145
148
  schemaVersion: 1,
146
149
  source: 'configuration',
147
150
  capabilities: Object.values(CAPABILITIES).map((capability) => {
151
+ const policy = functionPolicy(catalog.assignments?.[capability], capability);
152
+ const pool = functionModelPool(catalog.providers.filter(isConfiguredProvider), capability, catalog.assignments?.[capability]);
148
153
  const candidates = orderedModels(pool, capability, catalog.assignments?.[capability]).map((model) => model.ref);
149
- return { capability, candidates, ...(candidates.length ? {} : { gap: 'no_configured_model' }) };
154
+ return { capability, mode: policy.mode, candidates, ...(candidates.length ? {} : { gap: 'no_configured_model' }) };
150
155
  })
151
156
  };
152
157
  }
@@ -0,0 +1,16 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ // Resolved credentials live only in one operation's asynchronous context.
4
+ const scope = new AsyncLocalStorage();
5
+
6
+ export function scopedProvider(provider) {
7
+ return (provider.dshProvider && scope.getStore()?.get(provider.id)) || provider;
8
+ }
9
+
10
+ export function hasProviderConfigScope() {
11
+ return scope.getStore() !== undefined;
12
+ }
13
+
14
+ export function withProviderConfigScope(providers, callback) {
15
+ return scope.run(new Map(providers.map(provider => [provider.id, provider])), callback);
16
+ }
@@ -3,14 +3,16 @@ import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { loadProviderCatalog, providerCatalogSnapshot, catalogCapabilitySnapshot } from './provider-catalog.js';
5
5
  import { isKnownCapability } from './capability.js';
6
- import { providerModels, modelPool, parseModelRef, HOST_VISION_REF, capabilitiesOfDiscoveredModel, hasConfiguredModelCapabilities, resolvePoolModel } from './models.js';
6
+ import { providerModels, modelPool, functionPolicy, savedAssignmentRefs, parseModelRef, HOST_VISION_REF, capabilitiesOfDiscoveredModel, hasConfiguredModelCapabilities, resolvePoolModel } from './models.js';
7
7
  import { atomicWritePrivate, privateSibling, ensurePrivateDir } from './private-storage.js';
8
8
  import { createConfiguredProviderAdapter } from './provider-adapters.js';
9
- import { selectImageProtocol, normalizeImageProtocol, normalizeMusicProtocol } from './provider-protocol.js';
9
+ import { selectImageProtocol, normalizeImageProtocol, normalizeMusicProtocol, isConfiguredProvider } from './provider-protocol.js';
10
10
  import { invokeProviderOperation } from './provider-adapter.js';
11
11
  import { redactProviderMessage } from './provider-contract.js';
12
- import { modelRateLimit, recordModelRateLimit, clearModelRateLimit, clearModelCapabilityHealth, isDefinitiveHealthFailure } from './provider-health.js';
12
+ import { modelRateLimit, recordModelRateLimit, clearModelRateLimit, clearModelCapabilityHealth, isDefinitiveHealthFailure, recordModelAvailability, resetHealthOnCriticalChange } from './provider-health.js';
13
13
  import { normalizeVisionInputLimits, visionInputLimits } from './vision-image-input.js';
14
+ import { currentModelDirectory, modelDirectoryBinding, modelAvailabilityBinding } from './model-directory.js';
15
+ import { refreshModelDirectory } from './model-directory-service.js';
14
16
 
15
17
  const fail = message => { throw Object.assign(new Error(message), { code: 'IRIS_CONFIG_INPUT_INVALID' }); };
16
18
  const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
@@ -24,14 +26,18 @@ const caps = value => {
24
26
  function assignmentSnapshot(catalog) {
25
27
  const pool = modelPool(catalog.providers, { includeHostVision: true });
26
28
  return Object.fromEntries(Object.entries(catalog.assignments || {}).map(([capability, value]) => {
27
- const refs = (Array.isArray(value) ? value : [value]).map(ref => resolvePoolModel(pool, ref, capability));
28
- return [safe(capability), { model_refs: refs.filter(Boolean).map(model => safe(model.ref)), invalidRefs: refs.filter(ref => !ref).length }];
29
+ const policy = functionPolicy(value, capability);
30
+ const refs = policy.models.map(ref => resolvePoolModel(pool, ref, capability));
31
+ return [safe(capability), { mode: policy.mode, hostVision: policy.hostVision,
32
+ model_refs: refs.filter(Boolean).map(model => safe(model.ref)), invalidRefs: refs.filter(ref => !ref).length }];
29
33
  }));
30
34
  }
31
35
 
32
36
  export function configSnapshot(catalog) {
33
37
  return { ...providerCatalogSnapshot(catalog), models: catalog.providers.flatMap(provider => providerModels(provider).map(model => ({
34
38
  ref: safe(model.ref), providerId: safe(provider.id), id: safe(model.id), capabilities: model.capabilities,
39
+ ...(model.catalogStatus ? { catalogStatus: model.catalogStatus } : {}),
40
+ ...(model.unavailableCapabilities ? { unavailableCapabilities: model.unavailableCapabilities } : {}),
35
41
  ...(model.imageProtocol !== undefined ? { imageProtocol: model.imageProtocol } : {}),
36
42
  ...(model.musicProtocol !== undefined ? { musicProtocol: model.musicProtocol } : {}),
37
43
  ...(model.visionInput !== undefined ? { visionInput: model.visionInput } : {}),
@@ -73,7 +79,8 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
73
79
  }
74
80
  for (const [capability, assignment] of Object.entries(catalog.assignments || {})) {
75
81
  if (!isKnownCapability(capability)) issues.push('unknown_assignment_capability');
76
- for (const ref of (Array.isArray(assignment) ? assignment : [assignment])) if (!resolvePoolModel(pool, ref, capability)) issues.push('unavailable_assignment_model');
82
+ const policy = functionPolicy(assignment, capability);
83
+ if (policy.mode === 'manual') for (const ref of policy.models) if (!resolvePoolModel(pool, ref, capability)) issues.push('unavailable_assignment_model');
77
84
  }
78
85
  return { valid: issues.length === 0, issues: [...new Set(issues)] };
79
86
  }
@@ -116,6 +123,7 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
116
123
  clearModelCapabilityHealth(updated, model.id, 'image-gen');
117
124
  }
118
125
  }
126
+ resetHealthOnCriticalChange(provider, updated, value);
119
127
  }
120
128
  if (provider) catalog.providers[catalog.providers.indexOf(provider)] = updated;
121
129
  else catalog.providers.push(updated);
@@ -134,6 +142,9 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
134
142
  const models = discovered.models.map(model => ({ id: text(typeof model === 'string' ? model : model.id), capabilities: capabilitiesOfDiscoveredModel(model), source: 'discovered' }));
135
143
  result = { command, changed: input.apply === true, total: models.length, mediaCount: models.filter(model => model.capabilities.length).length, models: models.map(model => ({ ...model, id: safe(model.id) })) };
136
144
  if (input.apply !== true) return result;
145
+ provider.modelDirectory = { version: 1, binding: modelDirectoryBinding(provider), checkedAt: new Date().toISOString(),
146
+ models: models.map(({ id, capabilities }) => ({ id, capabilities })),
147
+ added: models.filter(model => !entries.some(entry => entry.id === model.id)).map(model => model.id) };
137
148
  for (const model of models) {
138
149
  const old = entries.find(value => value.id === model.id);
139
150
  if (!old) entries.push(model);
@@ -181,12 +192,14 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
181
192
  const tested = await probe({ provider, modelId, capability: input.capability, signal });
182
193
  if (tested.skipped) return { command, changed: false, ...tested };
183
194
  const evidence = { at: new Date().toISOString(), source: 'probe', category: tested.category || (tested.ok ? 'success' : 'unknown'),
195
+ ...(tested.providerCode ? { providerCode: safe(tested.providerCode) } : {}),
184
196
  ...(tested.httpStatus || tested.status ? { httpStatus: tested.httpStatus || tested.status } : {}) };
185
197
  entry.verified = { ...(entry.verified || {}), [input.capability]: { ok: tested.ok === true, ...evidence } };
186
198
  provider.health ||= { version: 1, revision: 0, observations: [] };
187
199
  const observations = provider.health.observations;
188
200
  let observation = observations.find(value => value.modelId === modelId && value.capability === input.capability);
189
201
  if (!observation) { observation = { modelId, capability: input.capability }; observations.push(observation); }
202
+ recordModelAvailability(provider, modelId, input.capability, { ...tested, ...evidence });
190
203
  if (tested.ok === true) { observation.lastSuccess = evidence; clearModelRateLimit(provider, modelId); }
191
204
  else {
192
205
  observation[isDefinitiveHealthFailure(evidence) ? 'lastFailure' : 'lastTransient'] = evidence;
@@ -195,11 +208,34 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
195
208
  result = { command, changed: true, passed: tested.ok === true, ...(tested.taskId ? { taskId: tested.taskId } : {}), ...(tested.code ? { code: tested.code } : {}) };
196
209
  } else fail('未知模型命令');
197
210
  provider.models = entries;
211
+ if (['models.add', 'models.caps'].includes(command) && input.enableAutomatic !== undefined) {
212
+ if (typeof input.enableAutomatic !== 'boolean') fail('enableAutomatic 必须是布尔值');
213
+ if (input.enableAutomatic) for (const capability of caps(input.capabilities)) {
214
+ catalog.assignments ||= {};
215
+ const previous = functionPolicy(catalog.assignments[capability], capability);
216
+ catalog.assignments[capability] = { mode: 'auto', models: savedAssignmentRefs(modelPool(catalog.providers, { includeHostVision: true }), capability, previous.models),
217
+ ...(capability === 'vision' ? { hostVision: previous.hostVision } : {}) };
218
+ }
219
+ }
198
220
  } else if (command === 'assignments.set' || command === 'assignments.clear') {
199
221
  if (!isKnownCapability(input.capability)) fail('未知能力');
200
222
  catalog.assignments ||= {};
201
223
  if (command === 'assignments.clear') delete catalog.assignments[input.capability];
202
- else {
224
+ else if (input.mode !== undefined) {
225
+ const previous = functionPolicy(catalog.assignments[input.capability], input.capability);
226
+ const next = { mode: input.mode, models: savedAssignmentRefs(modelPool(catalog.providers, { includeHostVision: true }), input.capability, previous.models),
227
+ ...(input.capability === 'vision' ? { hostVision: input.hostVision ?? previous.hostVision } : {}) };
228
+ functionPolicy(next, input.capability);
229
+ if (input.hostVision && input.capability !== 'vision') fail('hostVision 仅用于视觉');
230
+ if (input.model_refs !== undefined) {
231
+ if (!Array.isArray(input.model_refs) || new Set(input.model_refs).size !== input.model_refs.length) fail('model_refs 必须是不重复的引用数组');
232
+ const pool = modelPool(catalog.providers, { includeHostVision: true });
233
+ for (const ref of input.model_refs) if ((typeof ref !== 'string' || (!parseModelRef(ref) && ref !== HOST_VISION_REF))
234
+ || !resolvePoolModel(pool, ref, input.capability)) fail('分配模型不存在或没有该能力');
235
+ next.models = input.model_refs;
236
+ }
237
+ catalog.assignments[input.capability] = next;
238
+ } else {
203
239
  if (!Array.isArray(input.model_refs) || new Set(input.model_refs).size !== input.model_refs.length) fail('model_refs 必须是不重复的复合引用数组');
204
240
  const pool = modelPool(catalog.providers, { includeHostVision: true });
205
241
  for (const ref of input.model_refs) if ((!parseModelRef(ref) && ref !== HOST_VISION_REF) || !resolvePoolModel(pool, ref, input.capability)) fail('分配模型不存在或没有该能力');
@@ -211,8 +247,9 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
211
247
  if (command === 'providers.remove' || command === 'models.remove' || command === 'models.caps') {
212
248
  const pool = modelPool(catalog.providers);
213
249
  for (const [capability, assignment] of Object.entries(catalog.assignments || {})) {
214
- const values = (Array.isArray(assignment) ? assignment : [assignment]).filter(ref => resolvePoolModel(pool, ref, capability) || !resolvePoolModel(initialPool, ref, capability));
215
- if (!values.length) delete catalog.assignments[capability];
250
+ const values = functionPolicy(assignment, capability).models.filter(ref => resolvePoolModel(pool, ref, capability) || !resolvePoolModel(initialPool, ref, capability));
251
+ if (assignment && typeof assignment === 'object' && !Array.isArray(assignment) && Object.hasOwn(assignment, 'mode')) catalog.assignments[capability] = { ...assignment, models: values };
252
+ else if (!values.length) delete catalog.assignments[capability];
216
253
  else catalog.assignments[capability] = Array.isArray(assignment) ? values : values[0];
217
254
  }
218
255
  }
@@ -230,7 +267,37 @@ export function recordCatalogRateLimit(configPath, event) {
230
267
  return writeConfigChange(configPath, catalog, before, { changed: true });
231
268
  }
232
269
 
233
- function writeConfigChange(configPath, catalog, before, result) {
270
+ /** CLI真实提交的可用性证据,复用同一配置锁;不为可重建元数据新增备份。 */
271
+ export function recordCatalogAvailability(configPath, { providerId, modelId, capability, ...event }) {
272
+ const catalog = loadProviderCatalog(configPath);
273
+ const before = fs.readFileSync(configPath);
274
+ const provider = catalog.providers.find(item => item.id === providerId);
275
+ const cap = { image: 'image-gen', video: 'video-gen', music: 'music-gen' }[capability] || capability;
276
+ if (!provider || (event.binding && modelAvailabilityBinding(provider, modelId, cap) !== event.binding)
277
+ || !recordModelAvailability(provider, modelId, capability, event)) return;
278
+ const directory = currentModelDirectory(provider);
279
+ if (directory && event.ok !== true) directory.invalidatedAt = event.at || new Date().toISOString();
280
+ return writeConfigChange(configPath, catalog, before, { changed: true }, { backup: false });
281
+ }
282
+
283
+ /** 新的自动请求按需读取目录;只读CLI命令与手选/停用不走这里。 */
284
+ export async function refreshCatalogDirectories(configPath, catalog, capability, { signal } = {}) {
285
+ if (functionPolicy(catalog.assignments?.[capability], capability).mode !== 'auto') return [];
286
+ return Promise.all(catalog.providers.filter(provider => isConfiguredProvider(provider)
287
+ && providerModels(provider).some(model => model.capabilities.includes(capability))).map(provider =>
288
+ refreshModelDirectory(provider, { signal, save: directory => {
289
+ const fresh = loadProviderCatalog(configPath);
290
+ const before = fs.readFileSync(configPath);
291
+ const current = fresh.providers.find(item => item.id === provider.id);
292
+ if (!current || modelDirectoryBinding(current) !== directory.binding) return false;
293
+ current.modelDirectory = directory;
294
+ writeConfigChange(configPath, fresh, before, { changed: true }, { backup: false });
295
+ provider.modelDirectory = directory;
296
+ return true;
297
+ } })));
298
+ }
299
+
300
+ function writeConfigChange(configPath, catalog, before, result, { backup = true } = {}) {
234
301
  const lock = configPath + '.lock';
235
302
  let fd;
236
303
  try { fd = fs.openSync(lock, 'wx', 0o600); }
@@ -238,10 +305,12 @@ function writeConfigChange(configPath, catalog, before, result) {
238
305
  try {
239
306
  loadProviderCatalog(configPath);
240
307
  if (hash(fs.readFileSync(configPath)) !== hash(before)) throw Object.assign(new Error('配置在操作期间发生变化,请重新读取后重试'), { code: 'IRIS_CONFIG_CHANGED' });
241
- const backupPath = privateSibling(configPath, 'backup');
242
- fs.writeFileSync(backupPath, before, { flag: 'wx', mode: 0o600 });
308
+ if (backup) {
309
+ const backupPath = privateSibling(configPath, 'backup');
310
+ fs.writeFileSync(backupPath, before, { flag: 'wx', mode: 0o600 });
311
+ result.backupPath = backupPath;
312
+ }
243
313
  atomicWritePrivate(configPath, JSON.stringify(catalog, null, 2) + '\n');
244
- result.backupPath = backupPath;
245
314
  return result;
246
315
  } finally { fs.closeSync(fd); fs.unlinkSync(lock); }
247
316
  }
@@ -1,4 +1,37 @@
1
1
  'use strict';
2
+ import { isKnownCapability } from './capability.js';
3
+ import { isModelNotFound, modelAvailabilityBinding, modelUnavailable } from './model-directory.js';
4
+
5
+ const HEALTH_SENSITIVE_FIELDS = new Set([
6
+ 'auth', 'type', 'baseUrl', 'mediaBaseUrl', 'apiKey', 'mediaProtocol',
7
+ 'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel'
8
+ ]);
9
+
10
+ /** 明确修改连接后旧健康证据失效;额度停用仍需显式实测解除。 */
11
+ export function resetHealthOnCriticalChange(previous, next, patch) {
12
+ if (![...HEALTH_SENSITIVE_FIELDS].some(key => Object.hasOwn(patch, key) && previous[key] !== next[key])) return;
13
+ const current = previous.health;
14
+ next.health = { version: 1, revision: (Number.isSafeInteger(current?.revision) ? current.revision : 0) + 1,
15
+ observations: [], ...(current?.rateLimits?.length ? { rateLimits: current.rateLimits } : {}) };
16
+ }
17
+
18
+ /** 仅提交或显式实测更新模型可选性,远端旧任务的观察不解除阻断。 */
19
+ export function recordModelAvailability(provider, modelId, capability, result) {
20
+ const cap = { image: 'image-gen', video: 'video-gen', music: 'music-gen' }[capability] || capability;
21
+ if (!modelId || !isKnownCapability(cap)) return false;
22
+ let observation = provider.health?.observations?.find(item => item.modelId === modelId && item.capability === cap);
23
+ const blocked = isModelNotFound(result);
24
+ if (!blocked && !(result.ok === true && (result.source === 'probe' || result.stage === 'submit')
25
+ && (observation?.availability?.state === 'blocked' || modelUnavailable(provider, modelId, cap)))) return false;
26
+ provider.health ||= { version: 1, revision: 0, observations: [] };
27
+ provider.health.observations ||= [];
28
+ observation ||= provider.health.observations.find(item => item.modelId === modelId && item.capability === cap);
29
+ if (!observation) { observation = { modelId, capability: cap }; provider.health.observations.push(observation); }
30
+ observation.availability = { state: blocked ? 'blocked' : 'available', at: result.at || new Date().toISOString(),
31
+ binding: result.binding || modelAvailabilityBinding(provider, modelId, cap), source: result.source === 'probe' ? 'probe' : 'task',
32
+ ...(blocked ? { httpStatus: 404, reason: 'model_not_found' } : {}) };
33
+ return true;
34
+ }
2
35
 
3
36
  export const PROVIDER_HEALTH_SCHEMA_VERSION = 1;
4
37
  export const PROVIDER_HEALTH_FRESH_MS = 7 * 24 * 60 * 60 * 1000;
@@ -114,6 +114,36 @@ export function isConfiguredProvider(provider) {
114
114
  && (provider.auth === 'none' || (typeof provider.apiKey === 'string' && provider.apiKey.trim())));
115
115
  }
116
116
 
117
+ /** 已知需要其他接口的语音系列;自定义模型仍以明确配置和实测为准。 */
118
+ export function dashscopeTtsProtocolIssue(modelId) {
119
+ const model = String(modelId || '');
120
+ if (/^qwen(?:\d+(?:\.\d+)?)?-(?:tts|omni|audio)-.*realtime(?:-|$)/i.test(model)) {
121
+ return '该实时语音模型需要 WebSocket,Iris 尚未接入该调用方式';
122
+ }
123
+ if (/^(?:qwen-audio-|cosyvoice(?:-|$)|sambert-|MiniMax\/speech-)/i.test(model)) {
124
+ return 'Iris 尚未接入该语音系列的合成接口;当前使用 Qwen-TTS 普通 HTTP 接口';
125
+ }
126
+ return '';
127
+ }
128
+
129
+ /** 复刻/设计模型不能沿用普通模型的默认音色。 */
130
+ export function dashscopeTtsInputIssue(modelId, voice) {
131
+ return dashscopeTtsProtocolIssue(modelId)
132
+ || (/^qwen(?:\d+)?-tts-(?:vc|vd)(?:-|$)/i.test(String(modelId || '')) && !String(voice || '').trim()
133
+ ? '该模型需要先创建复刻或设计音色;请在语音功能填写与该模型匹配的音色 ID 后验证'
134
+ : '');
135
+ }
136
+
137
+ /** Protocol support is independent from model capability declarations and health. */
138
+ export function supportsModelCapability(provider, modelId, capability, { checkEndpoint = true } = {}) {
139
+ if (capability === 'vision') return true;
140
+ if (capability === 'music-gen') return provider?.models?.some(model => model?.id === modelId && model.musicProtocol === 'gemini-web-music') === true;
141
+ const protocol = capability === 'image-gen' ? selectImageProtocol(provider, modelId).mediaProtocol : selectMediaProtocol(provider).mediaProtocol;
142
+ if (protocol === 'dashscope') return (capability !== 'tts' || !dashscopeTtsProtocolIssue(modelId))
143
+ && (!checkEndpoint || isDashScopeBaseUrl(providerMediaBaseUrl(provider)));
144
+ return capability === 'image-gen' && ['openai-images', 'openai-chat-images', 'openai-responses-images'].includes(protocol);
145
+ }
146
+
117
147
  /**
118
148
  * 生图尺寸的协议差异集中在这里,调用点不再自带厂商假设。
119
149
  *