@mokuyoaxis/dsh-iris 0.2.5 → 0.2.7

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 (83) 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 +49 -1
  4. package/README.en.md +39 -22
  5. package/README.md +40 -21
  6. package/bin/dsh-iris.js +166 -35
  7. package/docs/AGENT_QUICKSTART.md +7 -7
  8. package/docs/ARCHITECTURE.md +9 -3
  9. package/docs/CLI_MANAGEMENT.md +5 -1
  10. package/docs/CORE_RUNTIME_CONTRACT.md +36 -5
  11. package/docs/CORE_SKILLS.md +62 -0
  12. package/docs/DSH_COMPATIBILITY.md +8 -4
  13. package/docs/DSH_CORE_MIGRATION.md +4 -2
  14. package/docs/DSH_PROVIDER_CONNECTIONS.md +34 -0
  15. package/docs/DSH_RUNTIME.md +36 -0
  16. package/docs/FUNCTION_MODEL_ASSIGNMENTS.md +2 -0
  17. package/docs/FUNCTION_MODEL_POOLS.md +84 -0
  18. package/docs/HEADLESS_CLI.md +16 -8
  19. package/docs/HOST_ADAPTER_CONTRACT.md +7 -2
  20. package/docs/HOST_DOCTOR.md +14 -2
  21. package/docs/IMAGE_FLOW.md +71 -0
  22. package/docs/LOCAL_API.md +60 -0
  23. package/docs/LOCAL_API_CONTRACT.md +115 -0
  24. package/docs/LOCAL_CREATION.md +50 -0
  25. package/docs/MUSIC.md +2 -2
  26. package/docs/PROVIDER_HEALTH.md +26 -2
  27. package/docs/ROADMAP.md +18 -4
  28. package/docs/SECURITY.md +20 -2
  29. package/docs/SYNC_DELIVERY_RECOVERY.md +2 -0
  30. package/docs/VISION_CLI.md +2 -2
  31. package/docs/WEB_PROTOTYPE.md +47 -0
  32. package/docs/releases/0.2.6.md +50 -0
  33. package/docs/releases/0.2.7.md +62 -0
  34. package/lib/actions.js +208 -90
  35. package/lib/adapters.js +36 -9
  36. package/lib/api.js +40 -39
  37. package/lib/application-runtime.js +186 -0
  38. package/lib/client.js +805 -182
  39. package/lib/composite-vision-routing.js +6 -6
  40. package/lib/config.js +140 -29
  41. package/lib/core-artifact-store.js +5 -0
  42. package/lib/core-maintenance.js +25 -2
  43. package/lib/core-runtime.js +19 -0
  44. package/lib/core-tasks.js +62 -8
  45. package/lib/creation-request-store.js +159 -0
  46. package/lib/doctor.js +20 -2
  47. package/lib/dsh-core-adapter.js +669 -564
  48. package/lib/dsh-host-adapter.js +14 -1
  49. package/lib/dsh-provider-config.js +35 -0
  50. package/lib/dsh-provider-source.js +102 -0
  51. package/lib/flow-store.js +195 -0
  52. package/lib/headless-vision.js +5 -5
  53. package/lib/host-contract.js +1 -0
  54. package/lib/http-vision-model-adapter.js +5 -2
  55. package/lib/image-flow.js +188 -0
  56. package/lib/index.js +103 -78
  57. package/lib/local-api-projection.js +27 -0
  58. package/lib/local-api.js +381 -0
  59. package/lib/local-creation.js +85 -0
  60. package/lib/model-directory-service.js +53 -0
  61. package/lib/model-directory.js +103 -0
  62. package/lib/models.js +71 -6
  63. package/lib/ocr-model-routing.js +2 -2
  64. package/lib/private-storage.js +12 -0
  65. package/lib/provider-adapters.js +42 -9
  66. package/lib/provider-catalog.js +14 -9
  67. package/lib/provider-config-scope.js +16 -0
  68. package/lib/provider-config-service.js +82 -13
  69. package/lib/provider-health.js +33 -0
  70. package/lib/provider-protocol.js +30 -0
  71. package/lib/provider-task-runner.js +22 -2
  72. package/lib/render-address.js +35 -0
  73. package/lib/render.js +52 -13
  74. package/lib/skill-installation.js +199 -0
  75. package/lib/vision-model-routing.js +25 -12
  76. package/lib/workbench-artifacts.js +1 -2
  77. package/lib/workbench-management.js +8 -9
  78. package/package.json +4 -3
  79. package/skills/iris-core/SKILL.md +40 -0
  80. package/skills/iris-core/agents/openai.yaml +4 -0
  81. package/skills/iris-core/references/cli.md +95 -0
  82. package/skills/iris-core/references/tasks.md +58 -0
  83. package/user_guide.md +32 -20
@@ -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) } : {}),
@@ -422,6 +422,11 @@ export function inspectCoreArtifact(dataRoot, id) {
422
422
  return publicArtifact(readArtifact(dataRoot, id).manifest);
423
423
  }
424
424
 
425
+ /** 先检查清单/普通文件大小,用于媒体边界限额;读取或提供字节时仍必须校验内容哈希。 */
426
+ export function inspectCoreArtifactMetadata(dataRoot, id) {
427
+ return publicArtifact(readArtifact(dataRoot, id, { verifyContent: false }).manifest);
428
+ }
429
+
425
430
  /** 供宿主投影层读取已经校验过内容哈希的字节;不暴露 Core 内部路径。 */
426
431
  export function readCoreArtifactBytes(dataRoot, id) {
427
432
  const hit = readArtifact(dataRoot, id);
@@ -2,9 +2,11 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { inspectCoreArtifact, refreshCoreArtifactInventory } from './core-artifacts.js';
5
- import { inspectCoreTask, listCoreTasks } from './core-tasks.js';
5
+ import { assertFlowTaskMatches, inspectCoreTask, listCoreTasks } from './core-tasks.js';
6
6
  import { atomicWritePrivate } from './private-storage.js';
7
7
  import { readDeliveryCache } from './core-delivery-cache.js';
8
+ import { flowReferences } from './flow-store.js';
9
+ import { creationReferences } from './creation-request-store.js';
8
10
 
9
11
  const TASK = /^task_[a-f0-9]{24}$/;
10
12
  const ARTIFACT = /^artifact_[a-f0-9]{24}$/;
@@ -51,7 +53,18 @@ function inventory(root) {
51
53
  }
52
54
  const artifacts = names(root, 'artifact-store/v0/records').filter(name => ARTIFACT.test(name.slice(0, -5)) && name.endsWith('.json'))
53
55
  .map(name => inspectCoreArtifact(root, name.slice(0, -5)));
54
- return { tasks, artifacts };
56
+ const flows = flowReferences(root);
57
+ for (const reference of flows) {
58
+ if (reference.run.edit.claimed && !tasks.some(task => task.id === reference.run.edit.taskId)) {
59
+ fail('IRIS_FLOW_TASK_CONFLICT', '流程已建立的 Task 丢失,停止管理');
60
+ }
61
+ }
62
+ for (const task of tasks.filter(task => task.flowBinding)) {
63
+ const reference = flows.find(item => item.run.id === task.flowBinding.runId);
64
+ if (!reference) fail('IRIS_FLOW_TASK_CONFLICT', '流程 Task 的 Run 记录丢失,停止管理');
65
+ assertFlowTaskMatches(task, reference.run);
66
+ }
67
+ return { tasks, artifacts, flows, creations: creationReferences(root) };
55
68
  }
56
69
 
57
70
  function ids(value, pattern, label) {
@@ -65,6 +78,14 @@ export function planCoreDeletion(root, input) {
65
78
  if (!taskIds.length && !artifactIds.length) fail('IRIS_COMMAND_INPUT_INVALID', '请明确指定 Task 或 Artifact ID');
66
79
  const existing = inventory(root), selectedTasks = new Set(taskIds), selectedArtifacts = new Set(artifactIds);
67
80
  const blockers = [], files = [];
81
+ for (const reference of existing.creations) {
82
+ for (const id of reference.taskIds) if (selectedTasks.has(id)) blockers.push({ id, referencedBy: reference.requestId, reason: 'creation_task_reference' });
83
+ for (const id of reference.artifactIds) if (selectedArtifacts.has(id)) blockers.push({ id, referencedBy: reference.requestId, reason: 'creation_artifact_reference' });
84
+ }
85
+ for (const reference of existing.flows) {
86
+ for (const id of reference.taskIds) if (selectedTasks.has(id)) blockers.push({ id, referencedBy: reference.run.id, reason: 'flow_task_reference' });
87
+ for (const id of reference.artifactIds) if (selectedArtifacts.has(id)) blockers.push({ id, referencedBy: reference.run.id, reason: 'flow_artifact_reference' });
88
+ }
68
89
  for (const id of taskIds) {
69
90
  const task = inspectCoreTask(root, id);
70
91
  if (task.phase !== 'terminal' || task.acceptance === 'unknown' || task.outcome === 'unknown') blockers.push({ id, reason: 'task_not_settled' });
@@ -115,6 +136,8 @@ export function planCoreCleanup(root, { older_than_ms = 3600000 } = {}) {
115
136
  if (!Number.isSafeInteger(older_than_ms) || older_than_ms < 60000) fail('IRIS_COMMAND_INPUT_INVALID', 'older_than_ms 至少为 60000');
116
137
  const existing = inventory(root);
117
138
  const referenced = new Set(existing.artifacts.map(artifact => artifact.id));
139
+ for (const reference of existing.creations) for (const id of reference.artifactIds) referenced.add(id);
140
+ for (const reference of existing.flows) for (const id of reference.artifactIds) referenced.add(id);
118
141
  for (const artifact of existing.artifacts) for (const edge of artifact.relations) referenced.add(edge.artifactId);
119
142
  for (const task of existing.tasks) for (const id of task.artifactIds) referenced.add(id);
120
143
  for (const task of existing.tasks) if (task.deliveryCache && task.deliveryState !== 'ready') {
@@ -13,6 +13,7 @@ import {
13
13
  transitionCoreLifecycle
14
14
  } from './core-contract.js';
15
15
  import { ensurePrivateDir } from './private-storage.js';
16
+ import { listCreationRequests } from './creation-request-store.js';
16
17
  import { WRITER_LEASE_DIR, WRITER_INTENT_FILE, publishLeaseRecord, readWriterLease,
17
18
  removeWriterIntent, retireLeaseDirectory, writerOwnerStatus } from './core-writer-lease.js';
18
19
 
@@ -162,6 +163,24 @@ export function createCoreRuntime(input) {
162
163
  }
163
164
  const candidateRoot = physicalDataRoot(options);
164
165
  const candidateLease = options.mode === 'writer' ? acquireWriterLease(candidateRoot, instanceId) : null;
166
+ if (candidateLease) {
167
+ try { listCreationRequests(candidateRoot); }
168
+ catch (error) {
169
+ try { releaseWriterLease(candidateLease); }
170
+ catch (cleanupError) {
171
+ // Retained ownership must also make dispose fail, so the application stops its writer queue.
172
+ root = candidateRoot; lease = candidateLease;
173
+ lifecycle = transitionCoreLifecycle(lifecycle, 'dispose');
174
+ abortController.abort();
175
+ const failure = Object.assign(new AggregateError([error, cleanupError], '请求区检查失败且租约未能安全释放'),
176
+ { code: 'IRIS_CORE_LEASE_INVALID' });
177
+ disposePromise = Promise.reject(failure);
178
+ void disposePromise.catch(() => {});
179
+ throw failure;
180
+ }
181
+ throw error;
182
+ }
183
+ }
165
184
  root = candidateRoot;
166
185
  lease = candidateLease;
167
186
  lifecycle = transitionCoreLifecycle(lifecycle, 'start');
package/lib/core-tasks.js CHANGED
@@ -3,11 +3,12 @@
3
3
  import crypto from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { atomicWritePrivate, ensurePrivateDir } from './private-storage.js';
6
+ import { atomicCreatePrivate, atomicWritePrivate, ensurePrivateDir } from './private-storage.js';
7
7
  import { parseModelRef } from './models.js';
8
8
  import { providerErrorRecord } from './provider-contract.js';
9
9
  import { deriveLegacyStatus, semanticViolations } from './task-semantics.js';
10
10
  import { readDeliveryCache } from './core-delivery-cache.js';
11
+ import { flowTaskBinding, readFlowRun, validFlowBinding } from './flow-store.js';
11
12
 
12
13
  const STORE_DIR = 'task-store';
13
14
  const STORE_VERSION = 'v0';
@@ -110,6 +111,7 @@ function validateRecord(record, expectedId) {
110
111
  || (record.deliveryCache !== undefined && record.deliveryCache !== true)
111
112
  || (record.providerBinding !== undefined && !PROVIDER_BINDING.test(record.providerBinding))
112
113
  || (record.retriedFrom !== undefined && !TASK_ID.test(record.retriedFrom))
114
+ || (record.flowBinding !== undefined && (record.capability !== 'image' || !validFlowBinding(record.flowBinding)))
113
115
  || (record.sourceArtifactId !== undefined && (record.capability !== 'image' || !ARTIFACT_ID.test(record.sourceArtifactId)))) {
114
116
  fail('IRIS_TASK_INVALID', 'Task 记录结构无效');
115
117
  }
@@ -143,11 +145,12 @@ function readRecord(dataRoot, id) {
143
145
  return validateRecord(record, id);
144
146
  }
145
147
 
146
- function writeRecord(dataRoot, record) {
148
+ function writeRecord(dataRoot, record, exclusive = false) {
147
149
  validateRecord(record, record.id);
148
150
  try {
149
151
  ensurePrivateDir(taskDirectory(dataRoot));
150
- atomicWritePrivate(taskFile(dataRoot, record.id), JSON.stringify(record, null, 2) + '\n');
152
+ const publish = exclusive ? atomicCreatePrivate : atomicWritePrivate;
153
+ publish(taskFile(dataRoot, record.id), JSON.stringify(record, null, 2) + '\n');
151
154
  } catch (error) {
152
155
  if (error instanceof CoreTaskError) throw error;
153
156
  fail('IRIS_TASK_WRITE_FAILED', 'Task 无法安全写入');
@@ -169,7 +172,7 @@ function safeError(error, overrides) {
169
172
  return providerErrorRecord(error, overrides);
170
173
  }
171
174
 
172
- export function createCoreTask(dataRoot, input = {}) {
175
+ function newTaskRecord(input, id) {
173
176
  const capability = String(input.capability || '').trim();
174
177
  if (!CAPABILITIES.has(capability)) fail('IRIS_TASK_INPUT_INVALID', 'Task capability 无效');
175
178
  const retriedFrom = input.retriedFrom === undefined ? undefined : String(input.retriedFrom);
@@ -177,10 +180,10 @@ export function createCoreTask(dataRoot, input = {}) {
177
180
  const sourceArtifactId = input.sourceArtifactId;
178
181
  if (sourceArtifactId !== undefined && (capability !== 'image' || !ARTIFACT_ID.test(sourceArtifactId))) fail('IRIS_TASK_INPUT_INVALID', '改图 sourceArtifactId 必须是合法 Artifact ID');
179
182
  const now = new Date().toISOString();
180
- return writeRecord(dataRoot, {
183
+ return {
181
184
  schemaVersion: 0,
182
185
  semanticsVersion: 2,
183
- id: 'task_' + crypto.randomBytes(12).toString('hex'),
186
+ id,
184
187
  capability,
185
188
  status: 'running',
186
189
  phase: 'queued',
@@ -197,7 +200,50 @@ export function createCoreTask(dataRoot, input = {}) {
197
200
  // D4 retry as new task:显式单向关系(新任务指向旧任务),绝不复制 Prompt。
198
201
  ...(retriedFrom !== undefined ? { retriedFrom } : {}),
199
202
  ...(sourceArtifactId !== undefined ? { sourceArtifactId } : {})
200
- });
203
+ };
204
+ }
205
+
206
+ export function createCoreTask(dataRoot, input = {}) {
207
+ return writeRecord(dataRoot, newTaskRecord(input, 'task_' + crypto.randomBytes(12).toString('hex')));
208
+ }
209
+
210
+ /** Flow 只能接回自己持久预留的 Task;原有 Task 没有该字段,读取语义不变。 */
211
+ export function assertFlowTaskMatches(task, run) {
212
+ const providerId = parseModelRef(run.input.model_ref).providerId;
213
+ if (task.id !== run.edit.taskId || task.capability !== 'image' || task.sourceArtifactId !== run.source.artifactId
214
+ || !validFlowBinding(task.flowBinding) || task.flowBinding.runId !== run.id
215
+ || task.flowBinding.inputDigest !== run.inputDigest
216
+ || task.attempts.some(attempt => attempt.providerId !== providerId
217
+ || attempt.model !== run.input.model_ref || attempt.providerBinding !== run.providerBinding)
218
+ || (task.attempts.length && (task.providerId !== providerId || task.modelRef !== run.input.model_ref || task.providerBinding !== run.providerBinding))) {
219
+ fail('IRIS_FLOW_TASK_CONFLICT', '流程与原 Task 的身份、输入或模型绑定冲突');
220
+ }
221
+ return task;
222
+ }
223
+
224
+ export function findFlowTask(dataRoot, run) {
225
+ for (const relative of [STORE_DIR, STORE_DIR + '/' + STORE_VERSION, STORE_DIR + '/' + STORE_VERSION + '/tasks']) {
226
+ try {
227
+ const stat = fs.lstatSync(path.join(dataRoot, relative));
228
+ if (!stat.isDirectory() || stat.isSymbolicLink()) fail('IRIS_FLOW_TASK_CONFLICT', '流程 Task 目录不安全');
229
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
230
+ }
231
+ try { fs.lstatSync(taskFile(dataRoot, run.edit.taskId)); }
232
+ catch (error) {
233
+ if (error.code !== 'ENOENT') throw error;
234
+ if (run.edit.claimed) fail('IRIS_FLOW_TASK_CONFLICT', '流程已建立的 Task 丢失,禁止重建或重新生成');
235
+ return null;
236
+ }
237
+ return assertFlowTaskMatches(readRecord(dataRoot, run.edit.taskId), run);
238
+ }
239
+
240
+ /** 仅从已提交的 Run 读取 ID;排他发布,新旧身份均不能覆盖。 */
241
+ export function claimFlowTask(dataRoot, runId) {
242
+ const run = readFlowRun(dataRoot, runId);
243
+ const previous = findFlowTask(dataRoot, run);
244
+ if (previous) return previous;
245
+ return writeRecord(dataRoot, { ...newTaskRecord({ capability: 'image', sourceArtifactId: run.source.artifactId }, run.edit.taskId),
246
+ flowBinding: flowTaskBinding(run) }, true);
201
247
  }
202
248
 
203
249
  export function inspectCoreTask(dataRoot, id) {
@@ -366,7 +412,7 @@ export function activateCoreWatch(dataRoot, taskId) {
366
412
  });
367
413
  }
368
414
 
369
- export function recordCorePollResult(dataRoot, taskId, result) {
415
+ export function recordCorePollResult(dataRoot, taskId, result, { deliveryCache = false } = {}) {
370
416
  return mutate(dataRoot, taskId, (task) => {
371
417
  if (task.acceptance !== 'accepted' || !task.remoteTaskId) {
372
418
  fail('IRIS_TASK_NOT_OBSERVABLE', 'Task 缺少可观察的远端受理事实');
@@ -387,6 +433,7 @@ export function recordCorePollResult(dataRoot, taskId, result) {
387
433
  task.phase = 'running';
388
434
  task.outcome = 'succeeded';
389
435
  task.deliveryState = 'pending';
436
+ if (deliveryCache) task.deliveryCache = true;
390
437
  delete task.lastError;
391
438
  return;
392
439
  }
@@ -524,6 +571,13 @@ export function recordCoreCancelResult(dataRoot, taskId, result) {
524
571
  */
525
572
  export function recoverCoreTask(dataRoot, taskId) {
526
573
  let current = readRecord(dataRoot, taskId);
574
+ // Flow 的异步完成结果同样先缓存,再记录 poll;中断时只恢复原结果,不重新查询/生成。
575
+ if (current.flowBinding && current.acceptance === 'accepted' && current.remoteTaskId
576
+ && current.outcome === 'none' && current.cancelState === 'none'
577
+ && readDeliveryCache(dataRoot, current, { allowAbsent: true })) {
578
+ recordCorePollResult(dataRoot, taskId, { kind: 'succeeded' }, { deliveryCache: true });
579
+ current = readRecord(dataRoot, taskId);
580
+ }
527
581
  if (current.phase === 'submitting' && current.acceptance === 'none') {
528
582
  const cache = readDeliveryCache(dataRoot, current, { allowAbsent: true });
529
583
  if (cache) {