@mokuyoaxis/dsh-iris 0.1.3 → 0.2.0

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 (91) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +23 -49
  2. package/.dsh/skills/iris-compose-media/references/task-v2-recovery.md +30 -0
  3. package/.dsh/skills/iris-compose-media/references/workflows.md +38 -0
  4. package/.dsh/skills/iris-verify-ui/SKILL.md +3 -25
  5. package/.dsh/skills/iris-verify-ui/agents/openai.yaml +4 -0
  6. package/.dsh/skills/iris-verify-ui/references/verdict-and-degradation.md +31 -0
  7. package/CHANGELOG.md +77 -2
  8. package/README.en.md +98 -19
  9. package/README.md +98 -19
  10. package/bin/dsh-iris.js +392 -13
  11. package/docs/AGENT_QUICKSTART.md +35 -0
  12. package/docs/ARCHITECTURE.md +82 -52
  13. package/docs/ARTIFACT_LIBRARY.md +24 -0
  14. package/docs/ARTIFACT_MANIFEST.md +60 -0
  15. package/docs/CORE_RUNTIME_CONTRACT.md +83 -0
  16. package/docs/DSH_CORE_MIGRATION.md +106 -0
  17. package/docs/DSH_RC2_ADAPTATION.md +36 -0
  18. package/docs/HEADLESS_CLI.md +217 -0
  19. package/docs/HOST_ADAPTER_CONTRACT.md +105 -0
  20. package/docs/HOST_DOCTOR.md +49 -0
  21. package/docs/MODEL_PORT_CONTRACT.md +300 -0
  22. package/docs/PROVIDER_ADAPTER_CONTRACT.md +138 -0
  23. package/docs/PROVIDER_HEALTH.md +39 -0
  24. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  25. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  26. package/docs/ROADMAP.md +42 -76
  27. package/docs/SECURITY.md +10 -5
  28. package/docs/TASK_SEMANTICS.md +3 -2
  29. package/docs/assets/diagrams/iris-architecture.png +0 -0
  30. package/docs/assets/diagrams/iris-architecture.svg +5 -5
  31. package/docs/assets/logo/README.md +55 -0
  32. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  33. package/docs/assets/logo/iris-bubble.svg +14 -0
  34. package/docs/assets/logo/iris-interim-flower.png +0 -0
  35. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  36. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  37. package/docs/assets/logo/iris-logo.svg +78 -0
  38. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  39. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  40. package/docs/assets/logo/iris-mark.svg +42 -0
  41. package/docs/releases/0.1.4.md +59 -0
  42. package/docs/releases/0.2.0.md +48 -0
  43. package/lib/actions.js +673 -525
  44. package/lib/adapters.js +58 -21
  45. package/lib/api.js +182 -16
  46. package/lib/artifacts.js +239 -0
  47. package/lib/bundled-skills.js +5 -5
  48. package/lib/client.js +1116 -86
  49. package/lib/command-service.js +420 -0
  50. package/lib/config.js +245 -49
  51. package/lib/core-artifact-store.js +598 -0
  52. package/lib/core-artifacts.js +4 -0
  53. package/lib/core-attention.js +103 -0
  54. package/lib/core-contract.js +105 -0
  55. package/lib/core-doctor.js +41 -0
  56. package/lib/core-lease-recovery.js +170 -0
  57. package/lib/core-runtime.js +232 -0
  58. package/lib/core-tasks.js +560 -0
  59. package/lib/core-user-projection.js +192 -0
  60. package/lib/doctor.js +260 -30
  61. package/lib/dsh-core-adapter.js +599 -0
  62. package/lib/dsh-host-adapter.js +240 -0
  63. package/lib/generation-input.js +95 -0
  64. package/lib/guard.js +3 -3
  65. package/lib/host-contract.js +131 -0
  66. package/lib/host-runtime.js +156 -0
  67. package/lib/index.js +255 -166
  68. package/lib/locate.js +1 -1
  69. package/lib/media.js +16 -6
  70. package/lib/model-invoker.js +218 -0
  71. package/lib/model-port-contract.js +333 -0
  72. package/lib/models.js +97 -11
  73. package/lib/prompt-optimizer.js +10 -20
  74. package/lib/provider-adapter.js +397 -0
  75. package/lib/provider-adapters.js +342 -0
  76. package/lib/provider-catalog.js +171 -0
  77. package/lib/provider-contract.js +72 -3
  78. package/lib/provider-health.js +59 -0
  79. package/lib/provider-protocol.js +51 -2
  80. package/lib/provider-task-runner.js +328 -0
  81. package/lib/render.js +43 -12
  82. package/lib/tasks.js +45 -9
  83. package/lib/vision.js +51 -56
  84. package/package.json +20 -14
  85. package/user_guide.md +67 -12
  86. package/docs/assets/diagrams/iris-architecture.drawio +0 -1
  87. package/docs/assets/diagrams/iris-task-lifecycle.drawio +0 -1
  88. package/docs/assets/diagrams/iris-workflow-compose.drawio +0 -1
  89. package/docs/releases/0.1.1.md +0 -51
  90. package/docs/releases/0.1.2.md +0 -26
  91. package/docs/releases/0.1.3.md +0 -30
package/lib/config.js CHANGED
@@ -10,7 +10,15 @@ import os from 'node:os';
10
10
  import * as cap from './capability.js';
11
11
  import * as models from './models.js';
12
12
  import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
13
- import { inferMediaProtocol } from './provider-protocol.js';
13
+ import { selectMediaProtocol, isConfiguredProvider } from './provider-protocol.js';
14
+ import { redactProviderMessage } from './provider-contract.js';
15
+ import {
16
+ PROVIDER_HEALTH_FRESH_MS,
17
+ aggregateHealthStates,
18
+ healthObservationState,
19
+ isDefinitiveHealthFailure,
20
+ newestHealthEvidence
21
+ } from './provider-health.js';
14
22
 
15
23
  function isPlainObject(value) {
16
24
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -24,6 +32,7 @@ function validateStoredConfig(value) {
24
32
  if (!isPlainObject(provider)) throw new Error('provider 条目必须是对象');
25
33
  if (provider.id !== undefined && typeof provider.id !== 'string') throw new Error('provider.id 必须是字符串');
26
34
  if (provider.baseUrl !== undefined && typeof provider.baseUrl !== 'string') throw new Error('provider.baseUrl 必须是字符串');
35
+ if (provider.mediaBaseUrl !== undefined && typeof provider.mediaBaseUrl !== 'string') throw new Error('provider.mediaBaseUrl 必须是字符串');
27
36
  if (provider.apiKey !== undefined && typeof provider.apiKey !== 'string') throw new Error('provider.apiKey 必须是字符串');
28
37
  if (provider.models !== undefined && !Array.isArray(provider.models)) throw new Error('provider.models 必须是数组');
29
38
  for (const model of provider.models || []) {
@@ -35,10 +44,27 @@ function validateStoredConfig(value) {
35
44
  }
36
45
  }
37
46
 
47
+ /**
48
+ * 供应商认证方式:'bearer'(默认,需要 apiKey)或 'none'(本地/内网无鉴权端点)。
49
+ * T-01:解除 ComfyUI、Ollama、本地 TTS 等本地部署的硬编码阻塞(审计 C-1)。
50
+ * 无 auth 字段时一律视为 'bearer'——现有用户行为完全不变。
51
+ */
52
+ function authModeOf(provider) {
53
+ return provider && provider.auth === 'none' ? 'none' : 'bearer';
54
+ }
55
+
56
+ /** 该供应商是否缺少必需凭据('none' 认证永不缺)。 */
57
+ function missingCredentials(provider) {
58
+ if (!provider || authModeOf(provider) === 'none') return false;
59
+ return typeof provider.apiKey !== 'string' || !provider.apiKey.trim();
60
+ }
61
+
38
62
  function normalizeStoredProvider(provider) {
39
- if (provider.mediaProtocol === 'dashscope' || provider.mediaProtocol === 'openai-images') return false;
40
- provider.mediaProtocol = inferMediaProtocol(provider.baseUrl);
41
- return true;
63
+ const selected = selectMediaProtocol(provider);
64
+ const changed = provider.mediaProtocol !== selected.mediaProtocol
65
+ || (provider.protocolInferred === true) !== selected.protocolInferred;
66
+ Object.assign(provider, selected);
67
+ return models.migrateProviderModels(provider) || changed;
42
68
  }
43
69
 
44
70
  export function irisHome() {
@@ -126,15 +152,17 @@ export function importFromWorkbench(workbenchConfigPath) {
126
152
  for (const p of list) {
127
153
  if (!p || typeof p.apiKey !== 'string' || !p.apiKey.trim() ||
128
154
  typeof p.baseUrl !== 'string' || !p.baseUrl.trim()) continue;
129
- c.providers.push({
155
+ const imported = {
130
156
  id: 'iris_' + Math.random().toString(36).slice(2, 8),
131
157
  name: p.name || p.id,
132
- type: p.type === 'anthropic' ? 'anthropic' : 'openai',
158
+ type: typeof p.type === 'string' && p.type.trim() ? p.type.trim() : 'openai',
133
159
  baseUrl: p.baseUrl,
134
160
  apiKey: p.apiKey,
135
161
  enabled: p.enabled !== false,
136
- mediaProtocol: inferMediaProtocol(p.baseUrl)
137
- });
162
+ mediaProtocol: p.mediaProtocol
163
+ };
164
+ normalizeStoredProvider(imported);
165
+ c.providers.push(imported);
138
166
  n++;
139
167
  }
140
168
  if (n) persist();
@@ -142,7 +170,7 @@ export function importFromWorkbench(workbenchConfigPath) {
142
170
  }
143
171
 
144
172
  export function providers() {
145
- return load().providers.filter((p) => p.enabled && p.apiKey);
173
+ return load().providers.filter(isConfiguredProvider);
146
174
  }
147
175
 
148
176
  export function providerById(id) {
@@ -181,16 +209,7 @@ export function pickFor(capability) {
181
209
  */
182
210
  export function pickAllFor(capability) {
183
211
  const pool = models.modelPool(providers());
184
- const chosen = [];
185
- const seen = new Set();
186
- const add = (m) => {
187
- const k = m.providerId + '\u0000' + m.id;
188
- if (!seen.has(k)) { seen.add(k); chosen.push(m); }
189
- };
190
- for (const ref of assignmentOrder(capability)) {
191
- for (const m of pool) if (m.ref === ref && m.capabilities.includes(capability)) add(m);
192
- }
193
- for (const m of pool) if (m.capabilities.includes(capability)) add(m);
212
+ const chosen = models.orderedModels(pool, capability, assignments()[capability]);
194
213
  const out = [];
195
214
  for (const m of chosen) {
196
215
  const p = providerById(m.providerId);
@@ -206,21 +225,7 @@ export function assignments() {
206
225
  return load().assignments || {};
207
226
  }
208
227
 
209
- function resolveAssignment(pool, raw, capability) {
210
- let providerId = '';
211
- let modelId = '';
212
- if (raw && typeof raw === 'object') {
213
- providerId = String(raw.providerId || '');
214
- modelId = String(raw.modelId || raw.id || '');
215
- } else if (typeof raw === 'string') {
216
- const parsed = models.parseModelRef(raw);
217
- if (parsed) ({ providerId, modelId } = parsed);
218
- else modelId = raw; // v1 旧格式:纯 model id,按池顺序取第一个
219
- }
220
- return pool.find((m) => m.id === modelId
221
- && (!providerId || m.providerId === providerId)
222
- && m.capabilities.includes(capability)) || null;
223
- }
228
+ const resolveAssignment = models.resolvePoolModel;
224
229
 
225
230
  /** 归一化某能力的有序分配列表 → [providerId::modelId, ...]。 */
226
231
  export function assignmentOrder(capability) {
@@ -293,6 +298,163 @@ export function capabilitiesOf(p) {
293
298
  return [...caps];
294
299
  }
295
300
 
301
+ const HEALTH_SENSITIVE_FIELDS = new Set([
302
+ 'auth', 'type', 'baseUrl', 'mediaBaseUrl', 'apiKey', 'mediaProtocol',
303
+ 'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel'
304
+ ]);
305
+
306
+ function healthContainer(provider) {
307
+ const current = provider.health;
308
+ if (current && current.version === 1 && Number.isSafeInteger(current.revision)
309
+ && Array.isArray(current.observations)) return current;
310
+ const revision = Number.isSafeInteger(current && current.revision) ? current.revision : 0;
311
+ provider.health = { version: 1, revision, observations: [] };
312
+ return provider.health;
313
+ }
314
+
315
+ function healthObservation(provider, modelId, capability) {
316
+ const health = provider && provider.health;
317
+ if (!health || health.version !== 1 || !Array.isArray(health.observations)) return null;
318
+ return health.observations.find((item) => item && item.modelId === modelId
319
+ && item.capability === capability) || null;
320
+ }
321
+
322
+ function safeHealthNote(provider, value) {
323
+ const raw = String(value ?? '').trim();
324
+ if (!raw) return '';
325
+ let note = redactProviderMessage(raw, 200);
326
+ const key = String(provider && provider.apiKey || '');
327
+ if (key) note = note.split(key).join('[REDACTED]');
328
+ return note;
329
+ }
330
+
331
+ function observationEvidence(provider, result, fallbackSource) {
332
+ const atMs = Date.parse(String(result && result.at || ''));
333
+ const status = Number(result && (result.httpStatus ?? result.status));
334
+ const note = safeHealthNote(provider, result && (result.note || result.safeMessage || result.message));
335
+ return {
336
+ at: new Date(Number.isFinite(atMs) ? atMs : Date.now()).toISOString(),
337
+ source: ['probe', 'task'].includes(result && result.source) ? result.source : fallbackSource,
338
+ category: String(result && result.category || (result && result.ok ? 'success' : 'unknown')).slice(0, 40),
339
+ ...(Number.isInteger(status) && status >= 100 && status <= 599 ? { httpStatus: status } : {}),
340
+ ...(note ? { note } : {})
341
+ };
342
+ }
343
+
344
+ function applyProviderHealth(provider, modelId, capability, result, fallbackSource = 'task') {
345
+ const model = String(modelId || '').trim();
346
+ if (!provider || !model || !cap.isKnownCapability(capability)) return false;
347
+ const health = healthContainer(provider);
348
+ let observation = health.observations.find((item) => item && item.modelId === model
349
+ && item.capability === capability);
350
+ if (!observation) {
351
+ observation = { modelId: model, capability };
352
+ health.observations.push(observation);
353
+ }
354
+ const evidence = observationEvidence(provider, result || {}, fallbackSource);
355
+ if (result && result.ok === true) {
356
+ observation.lastSuccess = evidence;
357
+ } else if (isDefinitiveHealthFailure({ ...result, ...evidence })) {
358
+ observation.lastFailure = evidence;
359
+ } else {
360
+ observation.lastTransient = evidence;
361
+ }
362
+ return true;
363
+ }
364
+
365
+ function pruneHealthToEffectiveModels(provider) {
366
+ if (!provider) return;
367
+ const allowedByModel = new Map();
368
+ for (const entry of models.providerModels(provider)) {
369
+ allowedByModel.set(entry.id, new Set(entry.capabilities));
370
+ }
371
+ if (provider.health && Array.isArray(provider.health.observations)) {
372
+ provider.health.observations = provider.health.observations.filter((item) =>
373
+ item && allowedByModel.get(item.modelId)?.has(item.capability));
374
+ }
375
+ for (const entry of provider.models || []) {
376
+ if (!entry || typeof entry !== 'object' || !entry.verified || typeof entry.verified !== 'object') continue;
377
+ const allowed = allowedByModel.get(entry.id) || new Set();
378
+ entry.verified = Object.fromEntries(Object.entries(entry.verified).filter(([capability]) => allowed.has(capability)));
379
+ if (!Object.keys(entry.verified).length) delete entry.verified;
380
+ }
381
+ }
382
+
383
+ function pruneModelHealth(provider, modelId) {
384
+ if (!provider || !provider.health || !Array.isArray(provider.health.observations)) return;
385
+ provider.health.observations = provider.health.observations.filter((item) => item.modelId !== modelId);
386
+ }
387
+
388
+ function resetHealthOnCriticalChange(previous, next, patch) {
389
+ const changed = [...HEALTH_SENSITIVE_FIELDS].some((key) =>
390
+ Object.prototype.hasOwnProperty.call(patch, key) && previous[key] !== next[key]);
391
+ if (!changed) return;
392
+ const current = previous.health;
393
+ const revision = Number.isSafeInteger(current && current.revision) ? current.revision + 1 : 1;
394
+ next.health = { version: 1, revision, observations: [] };
395
+ }
396
+
397
+ /** 记录一次显式实测或真实任务观察;临时错误不会覆盖近期成功。 */
398
+ export function recordProviderHealth(id, modelId, capability, result) {
399
+ const c = load();
400
+ const provider = c.providers.find((item) => item.id === id);
401
+ if (!provider || !applyProviderHealth(provider, modelId, capability, result, result && result.source || 'task')) {
402
+ return false;
403
+ }
404
+ persist();
405
+ return true;
406
+ }
407
+
408
+ /** 返回单个 Provider × Model × Capability 的安全状态。 */
409
+ export function modelHealth(id, modelId, capability, { now = Date.now() } = {}) {
410
+ const provider = load().providers.find((item) => item.id === id);
411
+ if (!provider || provider.enabled === false || missingCredentials(provider)) return { status: 'unconfigured' };
412
+ const observation = healthObservation(provider, modelId, capability);
413
+ const status = healthObservationState(observation, { now });
414
+ const evidence = status === 'verified'
415
+ ? observation && observation.lastSuccess
416
+ : (status === 'failed' ? observation && observation.lastFailure : newestHealthEvidence(observation));
417
+ return {
418
+ status,
419
+ ...(evidence ? {
420
+ observedAt: evidence.at,
421
+ source: evidence.source,
422
+ category: evidence.category,
423
+ ...(evidence.httpStatus ? { httpStatus: evidence.httpStatus } : {}),
424
+ ...(evidence.note ? { note: evidence.note } : {})
425
+ } : {})
426
+ };
427
+ }
428
+
429
+ /** 当前有序 failover 路径的健康快照;只含脱敏标量,不触发网络。 */
430
+ export function providerHealthSnapshot({ now = Date.now() } = {}) {
431
+ const capabilities = {};
432
+ for (const capability of Object.values(cap.CAPABILITIES)) {
433
+ const candidates = pickAllFor(capability).map((provider) => {
434
+ const modelId = String(provider[FIELD_OF[capability]] || '');
435
+ const health = modelHealth(provider.id, modelId, capability, { now });
436
+ return { providerId: provider.id, modelId, ...health };
437
+ });
438
+ const status = aggregateHealthStates(candidates.map((item) => item.status));
439
+ const newest = [...candidates]
440
+ .filter((item) => item.status === status && item.observedAt)
441
+ .sort((a, b) => String(b.observedAt).localeCompare(String(a.observedAt)))[0];
442
+ capabilities[capability] = {
443
+ status,
444
+ candidateCount: candidates.length,
445
+ ...(newest ? { observedAt: newest.observedAt } : {}),
446
+ candidates
447
+ };
448
+ }
449
+ return {
450
+ schemaVersion: 1,
451
+ freshForMs: PROVIDER_HEALTH_FRESH_MS,
452
+ generatedAt: new Date(Number(now)).toISOString(),
453
+ overall: aggregateHealthStates(Object.values(capabilities).map((item) => item.status)),
454
+ capabilities
455
+ };
456
+ }
457
+
296
458
  export function upsert(provider) {
297
459
  const c = load();
298
460
  const { id, ...input } = provider;
@@ -301,15 +463,19 @@ export function upsert(provider) {
301
463
  if (id) {
302
464
  const i = c.providers.findIndex((p) => p.id === id);
303
465
  if (i >= 0) {
304
- if (rest.mediaProtocol === 'auto') rest.mediaProtocol = inferMediaProtocol(rest.baseUrl ?? c.providers[i].baseUrl);
305
- c.providers[i] = { ...c.providers[i], ...rest };
306
- normalizeStoredProvider(c.providers[i]);
466
+ const previous = c.providers[i];
467
+ const next = { ...previous, ...rest };
468
+ if (Object.prototype.hasOwnProperty.call(rest, 'mediaProtocol')) next.protocolInferred = false;
469
+ normalizeStoredProvider(next);
470
+ resetHealthOnCriticalChange(previous, next, rest);
471
+ if (Object.prototype.hasOwnProperty.call(rest, 'models')) pruneHealthToEffectiveModels(next);
472
+ c.providers[i] = next;
307
473
  persist();
308
- return c.providers[i];
474
+ return next;
309
475
  }
310
476
  }
311
477
  const fresh = { id: 'iris_' + Math.random().toString(36).slice(2, 8), ...rest };
312
- if (fresh.mediaProtocol === 'auto') delete fresh.mediaProtocol;
478
+ fresh.protocolInferred = false;
313
479
  normalizeStoredProvider(fresh);
314
480
  c.providers.push(fresh);
315
481
  persist();
@@ -331,31 +497,46 @@ export function removeProvider(id) {
331
497
  return true;
332
498
  }
333
499
 
334
- /** 设置一个供应商的模型池(覆盖显式 models 数组;空数组 = 回退到自动发现/旧字段) */
500
+ /** 显式替换模型池;空数组表示没有可用模型。发现使用合并入口。 */
335
501
  export function setProviderModels(id, modelEntries) {
336
502
  const c = load();
337
503
  const p = c.providers.find((x) => x.id === id);
338
504
  if (!p) return null;
339
505
  if (Array.isArray(modelEntries)) {
340
- // 保留同名模型已有的 verified/source(重新发现不丢实测结果)
341
- const prev = {};
342
- for (const m of p.models || []) if (m && m.id) prev[m.id] = m;
506
+ // 保留同名模型的实测结果;手工能力标注优先于后续自动发现。
507
+ const prev = new Map();
508
+ for (const m of p.models || []) if (m && m.id) prev.set(m.id, m);
343
509
  p.models = modelEntries.map((m) => {
344
510
  const entry = typeof m === 'string' ? { id: m } : { id: m.id, capabilities: m.capabilities };
345
- const old = prev[entry.id];
511
+ const old = prev.get(entry.id);
346
512
  if (old) {
347
513
  if (old.verified) entry.verified = old.verified;
348
514
  if (old.source) entry.source = old.source;
515
+ if (old.source === 'manual' && Array.isArray(old.capabilities)) entry.capabilities = old.capabilities;
349
516
  }
350
517
  if (m && m.verified) entry.verified = m.verified;
351
518
  if (m && m.source) entry.source = m.source;
352
519
  return entry;
353
520
  });
354
521
  }
522
+ pruneHealthToEffectiveModels(p);
355
523
  persist();
356
524
  return p;
357
525
  }
358
526
 
527
+ /** 发现只追加或更新;用户手工项和未再返回的旧项始终保留。 */
528
+ export function mergeDiscoveredModels(id, modelEntries) {
529
+ const provider = load().providers.find((p) => p.id === id);
530
+ if (!provider) return null;
531
+ const merged = new Map(models.providerModels(provider).map((m) => [m.id, m]));
532
+ for (const model of modelEntries) {
533
+ const previous = merged.get(model.id);
534
+ if (previous?.source === 'manual') continue;
535
+ merged.set(model.id, { ...previous, ...model, source: 'discovered' });
536
+ }
537
+ return setProviderModels(id, [...merged.values()]);
538
+ }
539
+
359
540
  /** 找 provider 里的某个模型条目(可变引用) */
360
541
  function findModelEntry(p, modelId) {
361
542
  return (p.models || []).find((m) => m && m.id === modelId);
@@ -376,6 +557,8 @@ export function addProviderModel(id, modelId, capabilities) {
376
557
  } else if (Array.isArray(capabilities)) {
377
558
  entry.capabilities = capabilities;
378
559
  }
560
+ entry.source = 'manual';
561
+ pruneHealthToEffectiveModels(p);
379
562
  persist();
380
563
  return entry;
381
564
  }
@@ -388,6 +571,7 @@ export function removeProviderModel(id, modelId) {
388
571
  const i = p.models.findIndex((m) => m && m.id === modelId);
389
572
  if (i < 0) return false;
390
573
  p.models.splice(i, 1);
574
+ pruneModelHealth(p, modelId);
391
575
  persist();
392
576
  return true;
393
577
  }
@@ -399,20 +583,32 @@ export function setModelCapabilities(id, modelId, capabilities) {
399
583
  if (!p) return false;
400
584
  const entry = findModelEntry(p, modelId);
401
585
  if (!entry) return false;
402
- entry.capabilities = (Array.isArray(capabilities) ? capabilities : []).filter((x) => cap.isKnownCapability(x));
586
+ const nextCapabilities = (Array.isArray(capabilities) ? capabilities : []).filter((x) => cap.isKnownCapability(x));
587
+ entry.capabilities = nextCapabilities;
588
+ entry.source = 'manual';
589
+ pruneHealthToEffectiveModels(p);
403
590
  persist();
404
591
  return true;
405
592
  }
406
593
 
407
- /** 记录某模型的实测结果(verified[capability] = {ok, at, note}) */
594
+ /** 记录某模型的显式实测结果,并同步到新健康事实。 */
408
595
  export function setModelVerified(id, modelId, capability, result) {
409
596
  const c = load();
410
597
  const p = c.providers.find((x) => x.id === id);
411
598
  if (!p) return false;
412
599
  const entry = findModelEntry(p, modelId);
413
- if (!entry) return false;
414
- if (!entry.verified) entry.verified = {};
415
- entry.verified[capability] = { ok: !!result.ok, at: new Date().toISOString(), note: String(result.note || '').slice(0, 200) };
600
+ const at = new Date().toISOString();
601
+ if (entry) {
602
+ if (!entry.verified) entry.verified = {};
603
+ entry.verified[capability] = {
604
+ ok: !!result.ok,
605
+ at,
606
+ note: safeHealthNote(p, result.note || ''),
607
+ source: 'probe',
608
+ category: String(result.category || (result.ok ? 'success' : 'unknown')).slice(0, 40)
609
+ };
610
+ }
611
+ applyProviderHealth(p, modelId, capability, { ...result, at, source: 'probe' }, 'probe');
416
612
  persist();
417
613
  return true;
418
614
  }