@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
@@ -0,0 +1,103 @@
1
+ import crypto from 'node:crypto';
2
+ import { isKnownCapability } from './capability.js';
3
+ import { providerApiKey, providerMediaBaseUrl, selectMediaProtocol, providerForImageModel, providerForMusicModel } from './provider-protocol.js';
4
+
5
+ export const MODEL_DIRECTORY_TTL_MS = 10 * 60 * 1000;
6
+ export const MODEL_DIRECTORY_RETRY_MS = 30 * 1000;
7
+
8
+ /** 配置是外部输入,目录只接受不含连接凭据的固定数据形状。 */
9
+ export function validateModelDirectory(value) {
10
+ if (value === undefined) return;
11
+ if (!value || value.version !== 1 || !/^[a-f0-9]{64}$/.test(value.binding)
12
+ || (value.checkedAt !== undefined && !Number.isFinite(Date.parse(value.checkedAt)))
13
+ || (value.checkedAt && !Array.isArray(value.models))
14
+ || (value.models !== undefined && (!Array.isArray(value.models) || value.models.some(model =>
15
+ !model || typeof model.id !== 'string' || !model.id.trim() || !Array.isArray(model.capabilities)
16
+ || !model.capabilities.every(isKnownCapability))))
17
+ || (value.added !== undefined && (!Array.isArray(value.added) || !value.added.every(id => typeof id === 'string')))) {
18
+ throw new TypeError('模型目录快照结构无效');
19
+ }
20
+ }
21
+
22
+ /** 摘要绑定实际连接,不保存或回传密钥。 */
23
+ export function modelDirectoryBinding(provider) {
24
+ return crypto.createHash('sha256').update(JSON.stringify([
25
+ provider.id, provider.type || '', provider.baseUrl || '', providerMediaBaseUrl(provider), selectMediaProtocol(provider).mediaProtocol,
26
+ provider.auth || 'bearer', providerApiKey(provider)
27
+ ])).digest('hex');
28
+ }
29
+
30
+ export function currentModelDirectory(provider, { allowUnresolvedConnections = false } = {}) {
31
+ const directory = provider?.modelDirectory;
32
+ if (!directory || directory.version !== 1) return null;
33
+ if (directory.binding === modelDirectoryBinding(provider)) return directory;
34
+ return allowUnresolvedConnections && provider.dshProvider && !provider.baseUrl ? directory : null;
35
+ }
36
+
37
+ export function modelAvailabilityBinding(provider, modelId, capability) {
38
+ if (capability === 'image-gen') return modelDirectoryBinding(providerForImageModel(provider, modelId));
39
+ if (capability === 'music-gen' && provider.models?.some(model => model?.id === modelId && model.musicProtocol)) {
40
+ return modelDirectoryBinding(providerForMusicModel(provider, modelId));
41
+ }
42
+ return modelDirectoryBinding(provider);
43
+ }
44
+
45
+ export function modelDirectoryEntry(provider, modelId, options) {
46
+ return currentModelDirectory(provider, options)?.models?.find(model => model.id === modelId) || null;
47
+ }
48
+
49
+ export function modelDirectoryStatus(provider, modelId) {
50
+ const directory = currentModelDirectory(provider, { allowUnresolvedConnections: true });
51
+ if (!directory?.checkedAt) return 'unknown';
52
+ return directory.models.some(model => model.id === modelId) ? 'listed' : 'unlisted';
53
+ }
54
+
55
+ /** 普通请求路径404或参数400不代表模型不存在。兼容已有精确404记录。 */
56
+ export function isModelNotFound(result) {
57
+ if (result?.stage === 'poll') return false;
58
+ const status = Number(result?.httpStatus ?? result?.status);
59
+ if (status !== 404) return false;
60
+ const code = String(result?.providerCode || result?.code || '');
61
+ const message = String(result?.note || result?.safeMessage || result?.message || '');
62
+ return /^(?:NotFound\.Model|model_not_found|ModelNotFound)$/i.test(code)
63
+ || /\bModel not exist\./i.test(message);
64
+ }
65
+
66
+ /** 当前可选性与历史成功分开;旧Task成功不能解除新提交已确认的阻断。 */
67
+ export function modelUnavailable(provider, modelId, capability, { allowUnresolvedConnections = false } = {}) {
68
+ const observation = provider?.health?.observations?.find(item => item.modelId === modelId && item.capability === capability);
69
+ const availability = observation?.availability;
70
+ if (availability) {
71
+ if (availability.binding !== modelAvailabilityBinding(provider, modelId, capability)
72
+ && !(allowUnresolvedConnections && provider.dshProvider && !provider.baseUrl)) return null;
73
+ return availability.state === 'blocked' ? availability : null;
74
+ }
75
+ const failure = [observation?.lastTransient, observation?.lastFailure].filter(isModelNotFound)
76
+ .sort((a, b) => String(b.at).localeCompare(String(a.at)))[0];
77
+ if (!failure) return null;
78
+ if (observation.lastSuccess?.source === 'probe' && observation.lastSuccess.at >= failure.at) return null;
79
+ return { ...failure, state: 'blocked' };
80
+ }
81
+
82
+ export function modelUnavailableReason() {
83
+ return '当前连接已返回模型不存在,已暂停自动选用;请核对目录后单独实测恢复';
84
+ }
85
+
86
+ export function modelDirectorySummary(provider) {
87
+ const directory = currentModelDirectory(provider, { allowUnresolvedConnections: true });
88
+ if (!directory) return { status: 'unknown', note: '尚未同步当前连接的模型目录' };
89
+ const models = directory.models || [];
90
+ const configuredIds = Array.isArray(provider.models)
91
+ ? provider.models.map(model => typeof model === 'string' ? model : model.id)
92
+ : [...new Set(['imageModel', 'videoModel', 'ttsModel', 'musicModel', 'transcribeModel', 'visionModel']
93
+ .map(field => provider[field]).filter(id => typeof id === 'string' && id.trim()).map(id => id.trim()))];
94
+ const missing = directory.checkedAt ? configuredIds.filter(id => !models.some(model => model.id === id)) : [];
95
+ const stale = Boolean(directory.lastError) || Date.now() - Date.parse(directory.checkedAt || '') >= MODEL_DIRECTORY_TTL_MS
96
+ || Date.parse(directory.invalidatedAt || '') >= Date.parse(directory.checkedAt || '');
97
+ return {
98
+ status: directory.checkedAt ? stale ? 'stale' : 'current' : 'unknown',
99
+ checkedAt: directory.checkedAt || null, modelCount: models.length,
100
+ added: directory.added || [], missing,
101
+ ...(directory.lastError ? { error: directory.lastError, failedAt: directory.failedAt } : {})
102
+ };
103
+ }
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
+ }