@mokuyoaxis/dsh-iris 0.1.4 → 0.2.1

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 (110) hide show
  1. package/.dsh/skills/iris-compose-media/SKILL.md +10 -5
  2. package/.dsh/skills/iris-compose-media/references/workflows.md +7 -1
  3. package/.dsh/skills/iris-verify-ui/SKILL.md +2 -1
  4. package/CHANGELOG.md +99 -1
  5. package/README.en.md +124 -31
  6. package/README.md +124 -31
  7. package/bin/dsh-iris.js +593 -13
  8. package/docs/AGENT_QUICKSTART.md +39 -0
  9. package/docs/ARCHITECTURE.md +87 -64
  10. package/docs/ARTIFACT_LIBRARY.md +5 -5
  11. package/docs/ARTIFACT_MANIFEST.md +60 -0
  12. package/docs/CLI_MANAGEMENT.md +181 -0
  13. package/docs/COMPOSITE_VISION.md +35 -0
  14. package/docs/CORE_RUNTIME_CONTRACT.md +84 -0
  15. package/docs/DSH_CORE_MIGRATION.md +135 -0
  16. package/docs/DSH_RC2_ADAPTATION.md +38 -0
  17. package/docs/FAULT_INJECTION.md +12 -2
  18. package/docs/HEADLESS_CLI.md +223 -0
  19. package/docs/HOST_ADAPTER_CONTRACT.md +36 -17
  20. package/docs/HOST_DOCTOR.md +4 -2
  21. package/docs/MODEL_PORT_CONTRACT.md +305 -0
  22. package/docs/OCR_MODEL.md +39 -0
  23. package/docs/PROMPT_OPTIMIZER.md +47 -0
  24. package/docs/PROVIDER_ADAPTER_CONTRACT.md +53 -10
  25. package/docs/PROVIDER_HEALTH.md +22 -5
  26. package/docs/PROVIDER_RUNTIME_HARNESS.md +33 -0
  27. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +2 -2
  28. package/docs/ROADMAP.md +49 -83
  29. package/docs/SECURITY.md +8 -7
  30. package/docs/TASK_SEMANTICS.md +2 -2
  31. package/docs/VISION_CLI.md +132 -0
  32. package/docs/VISION_MODEL.md +51 -0
  33. package/docs/WORKBENCH_ARTIFACTS.md +67 -0
  34. package/docs/assets/logo/README.md +55 -0
  35. package/docs/assets/logo/iris-bubble-concept.svg +67 -0
  36. package/docs/assets/logo/iris-bubble.svg +14 -0
  37. package/docs/assets/logo/iris-interim-flower.png +0 -0
  38. package/docs/assets/logo/iris-logo-mono-inverse.svg +78 -0
  39. package/docs/assets/logo/iris-logo-mono.svg +78 -0
  40. package/docs/assets/logo/iris-logo.svg +78 -0
  41. package/docs/assets/logo/iris-mark-mono-inverse.svg +42 -0
  42. package/docs/assets/logo/iris-mark-mono.svg +42 -0
  43. package/docs/assets/logo/iris-mark.svg +42 -0
  44. package/docs/file-access-across-environments.md +8 -5
  45. package/docs/releases/0.2.0.md +48 -0
  46. package/docs/releases/0.2.1.md +56 -0
  47. package/lib/actions.js +520 -591
  48. package/lib/adapters.js +184 -36
  49. package/lib/api.js +200 -11
  50. package/lib/chromium-browser.js +117 -0
  51. package/lib/client.js +1390 -105
  52. package/lib/command-service.js +534 -0
  53. package/lib/composite-vision-routing.js +42 -0
  54. package/lib/config.js +143 -51
  55. package/lib/core-artifact-store.js +630 -0
  56. package/lib/core-artifacts.js +4 -0
  57. package/lib/core-attention.js +103 -0
  58. package/lib/core-contract.js +105 -0
  59. package/lib/core-doctor.js +41 -0
  60. package/lib/core-lease-recovery.js +170 -0
  61. package/lib/core-maintenance.js +213 -0
  62. package/lib/core-runtime.js +232 -0
  63. package/lib/core-tasks.js +567 -0
  64. package/lib/core-user-projection.js +193 -0
  65. package/lib/doctor.js +92 -33
  66. package/lib/dsh-core-adapter.js +653 -0
  67. package/lib/dsh-host-adapter.js +58 -7
  68. package/lib/dsh-text-model-adapter.js +222 -0
  69. package/lib/dsh-vision-model-adapter.js +75 -0
  70. package/lib/generation-input.js +126 -0
  71. package/lib/guard.js +3 -3
  72. package/lib/headless-vision.js +181 -0
  73. package/lib/host-runtime.js +1 -0
  74. package/lib/http-vision-model-adapter.js +187 -0
  75. package/lib/image-edit-input.js +34 -0
  76. package/lib/index.js +323 -188
  77. package/lib/locate.js +86 -28
  78. package/lib/media-probe.js +2 -1
  79. package/lib/model-call-runtime.js +45 -0
  80. package/lib/model-invoker.js +220 -0
  81. package/lib/model-port-contract.js +341 -0
  82. package/lib/models.js +105 -14
  83. package/lib/ocr-model-routing.js +26 -0
  84. package/lib/ocr.js +110 -64
  85. package/lib/prompt-optimizer-config.js +2 -1
  86. package/lib/prompt-optimizer-core.js +121 -0
  87. package/lib/prompt-optimizer.js +37 -193
  88. package/lib/provider-adapter.js +49 -23
  89. package/lib/provider-adapters.js +165 -26
  90. package/lib/provider-catalog.js +183 -0
  91. package/lib/provider-config-service.js +236 -0
  92. package/lib/provider-contract.js +74 -4
  93. package/lib/provider-health.js +80 -1
  94. package/lib/provider-protocol.js +74 -2
  95. package/lib/provider-task-runner.js +370 -0
  96. package/lib/summarize.js +31 -20
  97. package/lib/summary-input.js +52 -0
  98. package/lib/task-wait.js +40 -0
  99. package/lib/tasks.js +16 -4
  100. package/lib/video-input.js +30 -0
  101. package/lib/vision-core.js +42 -0
  102. package/lib/vision-image-input.js +83 -0
  103. package/lib/vision-input.js +31 -0
  104. package/lib/vision-model-routing.js +180 -0
  105. package/lib/vision.js +31 -9
  106. package/lib/workbench-artifacts.js +52 -0
  107. package/lib/workbench-management.js +151 -0
  108. package/lib/workbench-zip.js +39 -0
  109. package/package.json +8 -8
  110. package/user_guide.md +71 -26
package/lib/config.js CHANGED
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ import { normalizeVisionInputLimits } from './vision-image-input.js';
2
3
  /**
3
4
  * Iris 配置存储:$DSH_HOME/iris/v1/providers.json
4
5
  * Iris 独立持有配置;旧工作台仅作为用户显式指定的一次性导入来源。
@@ -10,14 +11,15 @@ import os from 'node:os';
10
11
  import * as cap from './capability.js';
11
12
  import * as models from './models.js';
12
13
  import { atomicWritePrivate, chmodPrivateFile, privateSibling } from './private-storage.js';
13
- import { inferMediaProtocol } from './provider-protocol.js';
14
+ import { selectMediaProtocol, selectImageProtocol, normalizeImageProtocol, isConfiguredProvider } from './provider-protocol.js';
14
15
  import { redactProviderMessage } from './provider-contract.js';
15
16
  import {
16
17
  PROVIDER_HEALTH_FRESH_MS,
17
18
  aggregateHealthStates,
18
19
  healthObservationState,
19
20
  isDefinitiveHealthFailure,
20
- newestHealthEvidence
21
+ newestHealthEvidence,
22
+ modelRateLimit, recordModelRateLimit, clearModelRateLimit, clearModelCapabilityHealth
21
23
  } from './provider-health.js';
22
24
 
23
25
  function isPlainObject(value) {
@@ -32,6 +34,7 @@ function validateStoredConfig(value) {
32
34
  if (!isPlainObject(provider)) throw new Error('provider 条目必须是对象');
33
35
  if (provider.id !== undefined && typeof provider.id !== 'string') throw new Error('provider.id 必须是字符串');
34
36
  if (provider.baseUrl !== undefined && typeof provider.baseUrl !== 'string') throw new Error('provider.baseUrl 必须是字符串');
37
+ if (provider.mediaBaseUrl !== undefined && typeof provider.mediaBaseUrl !== 'string') throw new Error('provider.mediaBaseUrl 必须是字符串');
35
38
  if (provider.apiKey !== undefined && typeof provider.apiKey !== 'string') throw new Error('provider.apiKey 必须是字符串');
36
39
  if (provider.models !== undefined && !Array.isArray(provider.models)) throw new Error('provider.models 必须是数组');
37
40
  for (const model of provider.models || []) {
@@ -43,10 +46,27 @@ function validateStoredConfig(value) {
43
46
  }
44
47
  }
45
48
 
49
+ /**
50
+ * 供应商认证方式:'bearer'(默认,需要 apiKey)或 'none'(本地/内网无鉴权端点)。
51
+ * T-01:解除 ComfyUI、Ollama、本地 TTS 等本地部署的硬编码阻塞(审计 C-1)。
52
+ * 无 auth 字段时一律视为 'bearer'——现有用户行为完全不变。
53
+ */
54
+ function authModeOf(provider) {
55
+ return provider && provider.auth === 'none' ? 'none' : 'bearer';
56
+ }
57
+
58
+ /** 该供应商是否缺少必需凭据('none' 认证永不缺)。 */
59
+ function missingCredentials(provider) {
60
+ if (!provider || authModeOf(provider) === 'none') return false;
61
+ return typeof provider.apiKey !== 'string' || !provider.apiKey.trim();
62
+ }
63
+
46
64
  function normalizeStoredProvider(provider) {
47
- if (provider.mediaProtocol === 'dashscope' || provider.mediaProtocol === 'openai-images') return false;
48
- provider.mediaProtocol = inferMediaProtocol(provider.baseUrl);
49
- return true;
65
+ const selected = selectMediaProtocol(provider);
66
+ const changed = provider.mediaProtocol !== selected.mediaProtocol
67
+ || (provider.protocolInferred === true) !== selected.protocolInferred;
68
+ Object.assign(provider, selected);
69
+ return models.migrateProviderModels(provider) || changed;
50
70
  }
51
71
 
52
72
  export function irisHome() {
@@ -134,15 +154,17 @@ export function importFromWorkbench(workbenchConfigPath) {
134
154
  for (const p of list) {
135
155
  if (!p || typeof p.apiKey !== 'string' || !p.apiKey.trim() ||
136
156
  typeof p.baseUrl !== 'string' || !p.baseUrl.trim()) continue;
137
- c.providers.push({
157
+ const imported = {
138
158
  id: 'iris_' + Math.random().toString(36).slice(2, 8),
139
159
  name: p.name || p.id,
140
- type: p.type === 'anthropic' ? 'anthropic' : 'openai',
160
+ type: typeof p.type === 'string' && p.type.trim() ? p.type.trim() : 'openai',
141
161
  baseUrl: p.baseUrl,
142
162
  apiKey: p.apiKey,
143
163
  enabled: p.enabled !== false,
144
- mediaProtocol: inferMediaProtocol(p.baseUrl)
145
- });
164
+ mediaProtocol: p.mediaProtocol
165
+ };
166
+ normalizeStoredProvider(imported);
167
+ c.providers.push(imported);
146
168
  n++;
147
169
  }
148
170
  if (n) persist();
@@ -150,7 +172,7 @@ export function importFromWorkbench(workbenchConfigPath) {
150
172
  }
151
173
 
152
174
  export function providers() {
153
- return load().providers.filter((p) => p.enabled && p.apiKey);
175
+ return load().providers.filter(isConfiguredProvider);
154
176
  }
155
177
 
156
178
  export function providerById(id) {
@@ -171,7 +193,7 @@ export function pickFor(capability) {
171
193
  const pool = models.modelPool(providers());
172
194
  let m = null;
173
195
  for (const ref of assignmentOrder(capability)) {
174
- m = pool.find((x) => x.ref === ref && x.capabilities.includes(capability));
196
+ m = pool.find((x) => !x.rateLimited && x.ref === ref && x.capabilities.includes(capability));
175
197
  if (m) break;
176
198
  }
177
199
  if (!m) m = models.pickModel(pool, capability);
@@ -187,18 +209,9 @@ export function pickFor(capability) {
187
209
  * 已分配顺序优先(阶段 6 条目 4),其余按池顺序补齐(去重)。
188
210
  * 一个 provider 若池里有多个该能力模型,会返回多条(各自带不同模型)。
189
211
  */
190
- export function pickAllFor(capability) {
212
+ export function pickAllFor(capability, { includeRateLimited = false } = {}) {
191
213
  const pool = models.modelPool(providers());
192
- const chosen = [];
193
- const seen = new Set();
194
- const add = (m) => {
195
- const k = m.providerId + '\u0000' + m.id;
196
- if (!seen.has(k)) { seen.add(k); chosen.push(m); }
197
- };
198
- for (const ref of assignmentOrder(capability)) {
199
- for (const m of pool) if (m.ref === ref && m.capabilities.includes(capability)) add(m);
200
- }
201
- for (const m of pool) if (m.capabilities.includes(capability)) add(m);
214
+ const chosen = models.orderedModels(pool, capability, assignments()[capability], { includeRateLimited });
202
215
  const out = [];
203
216
  for (const m of chosen) {
204
217
  const p = providerById(m.providerId);
@@ -214,21 +227,7 @@ export function assignments() {
214
227
  return load().assignments || {};
215
228
  }
216
229
 
217
- function resolveAssignment(pool, raw, capability) {
218
- let providerId = '';
219
- let modelId = '';
220
- if (raw && typeof raw === 'object') {
221
- providerId = String(raw.providerId || '');
222
- modelId = String(raw.modelId || raw.id || '');
223
- } else if (typeof raw === 'string') {
224
- const parsed = models.parseModelRef(raw);
225
- if (parsed) ({ providerId, modelId } = parsed);
226
- else modelId = raw; // v1 旧格式:纯 model id,按池顺序取第一个
227
- }
228
- return pool.find((m) => m.id === modelId
229
- && (!providerId || m.providerId === providerId)
230
- && m.capabilities.includes(capability)) || null;
231
- }
230
+ const resolveAssignment = models.resolvePoolModel;
232
231
 
233
232
  /** 归一化某能力的有序分配列表 → [providerId::modelId, ...]。 */
234
233
  export function assignmentOrder(capability) {
@@ -302,7 +301,7 @@ export function capabilitiesOf(p) {
302
301
  }
303
302
 
304
303
  const HEALTH_SENSITIVE_FIELDS = new Set([
305
- 'type', 'baseUrl', 'apiKey', 'mediaProtocol',
304
+ 'auth', 'type', 'baseUrl', 'mediaBaseUrl', 'apiKey', 'mediaProtocol',
306
305
  'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel'
307
306
  ]);
308
307
 
@@ -355,8 +354,10 @@ function applyProviderHealth(provider, modelId, capability, result, fallbackSour
355
354
  health.observations.push(observation);
356
355
  }
357
356
  const evidence = observationEvidence(provider, result || {}, fallbackSource);
357
+ recordModelRateLimit(provider, model, { ...evidence, retryAfterMs: result?.retryAfterMs, until: result?.until, reason: result?.reason });
358
358
  if (result && result.ok === true) {
359
359
  observation.lastSuccess = evidence;
360
+ if (evidence.source === 'probe') clearModelRateLimit(provider, model);
360
361
  } else if (isDefinitiveHealthFailure({ ...result, ...evidence })) {
361
362
  observation.lastFailure = evidence;
362
363
  } else {
@@ -394,10 +395,11 @@ function resetHealthOnCriticalChange(previous, next, patch) {
394
395
  if (!changed) return;
395
396
  const current = previous.health;
396
397
  const revision = Number.isSafeInteger(current && current.revision) ? current.revision + 1 : 1;
397
- next.health = { version: 1, revision, observations: [] };
398
+ next.health = { version: 1, revision, observations: [],
399
+ ...(current?.rateLimits?.length ? { rateLimits: current.rateLimits } : {}) };
398
400
  }
399
401
 
400
- /** 记录一次显式实测或真实任务观察;临时错误不会覆盖近期成功。 */
402
+ /** 记录显式实测或真实任务观察;限流资格另存,保留原成功事实。 */
401
403
  export function recordProviderHealth(id, modelId, capability, result) {
402
404
  const c = load();
403
405
  const provider = c.providers.find((item) => item.id === id);
@@ -408,10 +410,24 @@ export function recordProviderHealth(id, modelId, capability, result) {
408
410
  return true;
409
411
  }
410
412
 
413
+ /** DSH 调用边界记录冷却或明确耗尽;覆盖同账号该模型的所有能力。 */
414
+ export function recordRateLimit({ providerId, modelId, capability, httpStatus = 429, category = 'rate_limit', at, until, retryAfterMs, reason }) {
415
+ const known = { image: 'image-gen', video: 'video-gen' }[capability] || capability;
416
+ return recordProviderHealth(providerId, modelId, known, {
417
+ ok: false, source: 'task', category, httpStatus, at, until, retryAfterMs, reason,
418
+ note: category === 'quota' ? '额度或预算耗尽后已停用,手动实测成功后恢复' : '429 限流冷却,暂时跳过'
419
+ });
420
+ }
421
+
411
422
  /** 返回单个 Provider × Model × Capability 的安全状态。 */
412
423
  export function modelHealth(id, modelId, capability, { now = Date.now() } = {}) {
413
424
  const provider = load().providers.find((item) => item.id === id);
414
- if (!provider || provider.enabled === false || !provider.apiKey) return { status: 'unconfigured' };
425
+ if (!provider || provider.enabled === false || missingCredentials(provider)) return { status: 'unconfigured' };
426
+ const blocked = modelRateLimit(provider, modelId, { now });
427
+ if (blocked) return { status: 'failed', rateLimited: true, observedAt: blocked.at,
428
+ source: blocked.source, category: blocked.category, httpStatus: blocked.httpStatus,
429
+ ...(blocked.until ? { retryAt: blocked.until } : { reason: blocked.reason }),
430
+ note: blocked.until ? '429 限流冷却,暂时跳过' : '额度或预算耗尽后已停用,手动实测成功后恢复' };
415
431
  const observation = healthObservation(provider, modelId, capability);
416
432
  const status = healthObservationState(observation, { now });
417
433
  const evidence = status === 'verified'
@@ -433,7 +449,7 @@ export function modelHealth(id, modelId, capability, { now = Date.now() } = {})
433
449
  export function providerHealthSnapshot({ now = Date.now() } = {}) {
434
450
  const capabilities = {};
435
451
  for (const capability of Object.values(cap.CAPABILITIES)) {
436
- const candidates = pickAllFor(capability).map((provider) => {
452
+ const candidates = pickAllFor(capability, { includeRateLimited: true }).map((provider) => {
437
453
  const modelId = String(provider[FIELD_OF[capability]] || '');
438
454
  const health = modelHealth(provider.id, modelId, capability, { now });
439
455
  return { providerId: provider.id, modelId, ...health };
@@ -446,14 +462,21 @@ export function providerHealthSnapshot({ now = Date.now() } = {}) {
446
462
  status,
447
463
  candidateCount: candidates.length,
448
464
  ...(newest ? { observedAt: newest.observedAt } : {}),
465
+ ...(status === 'failed' && candidates.every(item => item.retryAt) ? {
466
+ rateLimited: true, retryAt: candidates.map(item => item.retryAt).sort()[0]
467
+ } : {}),
449
468
  candidates
450
469
  };
451
470
  }
471
+ const overall = aggregateHealthStates(Object.values(capabilities).map((item) => item.status));
472
+ const configured = Object.values(capabilities).filter(item => item.status !== 'unconfigured');
452
473
  return {
453
474
  schemaVersion: 1,
454
475
  freshForMs: PROVIDER_HEALTH_FRESH_MS,
455
476
  generatedAt: new Date(Number(now)).toISOString(),
456
- overall: aggregateHealthStates(Object.values(capabilities).map((item) => item.status)),
477
+ overall,
478
+ ...(overall === 'failed' && configured.every(item => item.retryAt)
479
+ ? { retryAt: configured.map(item => item.retryAt).sort()[0] } : {}),
457
480
  capabilities
458
481
  };
459
482
  }
@@ -463,14 +486,19 @@ export function upsert(provider) {
463
486
  const { id, ...input } = provider;
464
487
  // PATCH 语义:缺失字段和 undefined 都不覆盖旧值;null/空串仍是显式更新。
465
488
  const rest = Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
489
+ if (rest.visionInput !== undefined) rest.visionInput = normalizeVisionInputLimits(rest.visionInput) || null;
490
+ if (Array.isArray(rest.models)) rest.models = rest.models.map(model => typeof model === 'string' ? model : ({ ...model,
491
+ ...(model?.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(model.imageProtocol) } : {}),
492
+ ...(model?.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(model.visionInput) || null } : {}) }));
466
493
  if (id) {
467
494
  const i = c.providers.findIndex((p) => p.id === id);
468
495
  if (i >= 0) {
469
496
  const previous = c.providers[i];
470
- if (rest.mediaProtocol === 'auto') rest.mediaProtocol = inferMediaProtocol(rest.baseUrl ?? previous.baseUrl);
471
497
  const next = { ...previous, ...rest };
498
+ if (Object.prototype.hasOwnProperty.call(rest, 'mediaProtocol')) next.protocolInferred = false;
472
499
  normalizeStoredProvider(next);
473
500
  resetHealthOnCriticalChange(previous, next, rest);
501
+ resetChangedImageProtocolHealth(previous, next);
474
502
  if (Object.prototype.hasOwnProperty.call(rest, 'models')) pruneHealthToEffectiveModels(next);
475
503
  c.providers[i] = next;
476
504
  persist();
@@ -478,7 +506,7 @@ export function upsert(provider) {
478
506
  }
479
507
  }
480
508
  const fresh = { id: 'iris_' + Math.random().toString(36).slice(2, 8), ...rest };
481
- if (fresh.mediaProtocol === 'auto') delete fresh.mediaProtocol;
509
+ fresh.protocolInferred = false;
482
510
  normalizeStoredProvider(fresh);
483
511
  c.providers.push(fresh);
484
512
  persist();
@@ -500,37 +528,99 @@ export function removeProvider(id) {
500
528
  return true;
501
529
  }
502
530
 
503
- /** 设置一个供应商的模型池(覆盖显式 models 数组;空数组 = 回退到自动发现/旧字段) */
531
+ /** 显式替换模型池;空数组表示没有可用模型。发现使用合并入口。 */
504
532
  export function setProviderModels(id, modelEntries) {
505
533
  const c = load();
506
534
  const p = c.providers.find((x) => x.id === id);
507
535
  if (!p) return null;
536
+ const previous = { ...p };
508
537
  if (Array.isArray(modelEntries)) {
509
- // 保留同名模型已有的 verified/source(重新发现不丢实测结果)
510
- const prev = {};
511
- for (const m of p.models || []) if (m && m.id) prev[m.id] = m;
538
+ // 保留同名模型的实测结果;手工能力标注优先于后续自动发现。
539
+ const prev = new Map();
540
+ for (const m of p.models || []) if (m && m.id) prev.set(m.id, m);
512
541
  p.models = modelEntries.map((m) => {
513
- const entry = typeof m === 'string' ? { id: m } : { id: m.id, capabilities: m.capabilities };
514
- const old = prev[entry.id];
542
+ const entry = typeof m === 'string' ? { id: m } : { id: m.id, capabilities: m.capabilities,
543
+ ...(m.imageProtocol !== undefined ? { imageProtocol: normalizeImageProtocol(m.imageProtocol) } : {}),
544
+ ...(m.visionInput !== undefined ? { visionInput: normalizeVisionInputLimits(m.visionInput) || null } : {}) };
545
+ const old = prev.get(entry.id);
515
546
  if (old) {
516
547
  if (old.verified) entry.verified = old.verified;
517
548
  if (old.source) entry.source = old.source;
549
+ if (old.source === 'manual' && Array.isArray(old.capabilities)) entry.capabilities = old.capabilities;
550
+ if (m?.imageProtocol === undefined && old.imageProtocol !== undefined) entry.imageProtocol = old.imageProtocol;
551
+ if (m?.visionInput === undefined && old.visionInput !== undefined) entry.visionInput = old.visionInput;
518
552
  }
519
553
  if (m && m.verified) entry.verified = m.verified;
520
554
  if (m && m.source) entry.source = m.source;
521
555
  return entry;
522
556
  });
523
557
  }
558
+ resetChangedImageProtocolHealth(previous, p);
524
559
  pruneHealthToEffectiveModels(p);
525
560
  persist();
526
561
  return p;
527
562
  }
528
563
 
564
+ /** 发现只追加或更新;用户手工项和未再返回的旧项始终保留。 */
565
+ export function mergeDiscoveredModels(id, modelEntries) {
566
+ const provider = load().providers.find((p) => p.id === id);
567
+ if (!provider) return null;
568
+ const merged = new Map(models.providerModels(provider).map((m) => [m.id, m]));
569
+ for (const model of modelEntries) {
570
+ const previous = merged.get(model.id);
571
+ if (previous?.source === 'manual') continue;
572
+ merged.set(model.id, { ...previous, ...model, source: 'discovered' });
573
+ }
574
+ return setProviderModels(id, [...merged.values()]);
575
+ }
576
+
529
577
  /** 找 provider 里的某个模型条目(可变引用) */
530
578
  function findModelEntry(p, modelId) {
531
579
  return (p.models || []).find((m) => m && m.id === modelId);
532
580
  }
533
581
 
582
+ function resetChangedImageProtocolHealth(previous, next) {
583
+ for (const model of models.providerModels(previous)) {
584
+ if (selectImageProtocol(previous, model.id).mediaProtocol !== selectImageProtocol(next, model.id).mediaProtocol) {
585
+ clearModelCapabilityHealth(next, model.id, cap.CAPABILITIES.IMAGE);
586
+ }
587
+ }
588
+ }
589
+
590
+ /** 手动选择单模型图片协议;auto 恢复账号默认,不解除模型停用。 */
591
+ export function setModelImageProtocol(id, modelId, value) {
592
+ const protocol = normalizeImageProtocol(value);
593
+ const p = load().providers.find(provider => provider.id === id);
594
+ if (!p) return false;
595
+ const entries = Array.isArray(p.models) ? p.models : models.providerModels(p).map(({ id, capabilities }) => ({ id, capabilities }));
596
+ const index = entries.findIndex(model => (typeof model === 'string' ? model : model?.id)?.trim() === modelId);
597
+ if (index < 0) return false;
598
+ p.models = entries;
599
+ const entry = typeof p.models[index] === 'string' ? { id: p.models[index] } : p.models[index];
600
+ p.models[index] = entry;
601
+ const previous = selectImageProtocol(p, modelId).mediaProtocol;
602
+ if (protocol === 'auto') delete entry.imageProtocol;
603
+ else entry.imageProtocol = protocol;
604
+ if (previous !== selectImageProtocol(p, modelId).mediaProtocol) clearModelCapabilityHealth(p, modelId, cap.CAPABILITIES.IMAGE);
605
+ persist();
606
+ return true;
607
+ }
608
+
609
+ /** 看图预算变更不会伪造实测成功、解除额度停用或改写原图。 */
610
+ export function setModelVisionInput(id, modelId, value) {
611
+ const limits = normalizeVisionInputLimits(value);
612
+ const p = load().providers.find(provider => provider.id === id);
613
+ if (!p) return false;
614
+ const entries = (Array.isArray(p.models) ? p.models : models.providerModels(p)).map(model => typeof model === 'string' ? { id: model } : { ...model });
615
+ const entry = entries.find(model => model.id === modelId);
616
+ if (!entry) return false;
617
+ if (limits) entry.visionInput = limits;
618
+ else delete entry.visionInput;
619
+ p.models = entries;
620
+ persist();
621
+ return true;
622
+ }
623
+
534
624
  /** 手动添加一个模型到池(source=manual,能力由调用方给) */
535
625
  export function addProviderModel(id, modelId, capabilities) {
536
626
  const c = load();
@@ -546,6 +636,7 @@ export function addProviderModel(id, modelId, capabilities) {
546
636
  } else if (Array.isArray(capabilities)) {
547
637
  entry.capabilities = capabilities;
548
638
  }
639
+ entry.source = 'manual';
549
640
  pruneHealthToEffectiveModels(p);
550
641
  persist();
551
642
  return entry;
@@ -573,6 +664,7 @@ export function setModelCapabilities(id, modelId, capabilities) {
573
664
  if (!entry) return false;
574
665
  const nextCapabilities = (Array.isArray(capabilities) ? capabilities : []).filter((x) => cap.isKnownCapability(x));
575
666
  entry.capabilities = nextCapabilities;
667
+ entry.source = 'manual';
576
668
  pruneHealthToEffectiveModels(p);
577
669
  persist();
578
670
  return true;