@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.
- package/.dsh/skills/iris-use/SKILL.md +2 -2
- package/.dsh/skills/iris-use/references/media-contract.md +2 -2
- package/CHANGELOG.md +38 -1
- package/README.en.md +34 -41
- package/README.md +35 -40
- package/bin/dsh-iris.js +29 -12
- package/docs/AGENT_QUICKSTART.md +11 -13
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CLI_MANAGEMENT.md +5 -1
- package/docs/DSH_COMPATIBILITY.md +38 -8
- package/docs/DSH_PROVIDER_CONNECTIONS.md +34 -0
- package/docs/DSH_RC2_ADAPTATION.md +1 -1
- package/docs/FUNCTION_MODEL_ASSIGNMENTS.md +2 -0
- package/docs/FUNCTION_MODEL_POOLS.md +84 -0
- package/docs/HEADLESS_CLI.md +7 -7
- package/docs/HOST_ADAPTER_CONTRACT.md +8 -2
- package/docs/HOST_DOCTOR.md +3 -3
- package/docs/MUSIC.md +2 -2
- package/docs/PROVIDER_HEALTH.md +26 -2
- package/docs/ROADMAP.md +8 -4
- package/docs/SECURITY.md +12 -2
- package/docs/VISION_CLI.md +2 -2
- package/docs/releases/0.2.5.md +33 -0
- package/docs/releases/0.2.6.md +50 -0
- package/lib/actions.js +137 -30
- package/lib/adapters.js +36 -9
- package/lib/api.js +2 -1
- package/lib/client.js +717 -180
- package/lib/composite-vision-routing.js +6 -6
- package/lib/config.js +140 -29
- package/lib/doctor.js +9 -2
- package/lib/dsh-compatibility.js +7 -1
- package/lib/dsh-core-adapter.js +22 -11
- package/lib/dsh-host-adapter.js +32 -29
- package/lib/dsh-provider-config.js +35 -0
- package/lib/dsh-provider-source.js +102 -0
- package/lib/headless-vision.js +5 -5
- package/lib/host-contract.js +1 -0
- package/lib/http-vision-model-adapter.js +5 -2
- package/lib/index.js +54 -35
- package/lib/model-directory-service.js +53 -0
- package/lib/model-directory.js +103 -0
- package/lib/models.js +71 -6
- package/lib/ocr-model-routing.js +2 -2
- package/lib/provider-adapters.js +42 -9
- package/lib/provider-catalog.js +14 -9
- package/lib/provider-config-scope.js +16 -0
- package/lib/provider-config-service.js +82 -13
- package/lib/provider-health.js +33 -0
- package/lib/provider-protocol.js +30 -0
- package/lib/vision-model-routing.js +25 -12
- package/package.json +9 -9
- package/user_guide.md +28 -29
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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) } : {}),
|
package/lib/doctor.js
CHANGED
|
@@ -103,16 +103,23 @@ function inspectConfig(dataDir, checks) {
|
|
|
103
103
|
let incomplete = 0;
|
|
104
104
|
let enabled = 0;
|
|
105
105
|
let models = 0;
|
|
106
|
+
let hostReferences = 0;
|
|
106
107
|
for (const provider of providers) {
|
|
107
108
|
if (!plain(provider)) { incomplete++; continue; }
|
|
108
109
|
if (provider.enabled !== false) enabled++;
|
|
109
|
-
|
|
110
|
+
const reference = provider.dshProvider;
|
|
111
|
+
const managed = plain(reference) && typeof reference.provider === 'string' && reference.provider.trim()
|
|
112
|
+
&& typeof reference.settingsNs === 'string' && reference.settingsNs.trim();
|
|
113
|
+
if (provider.enabled !== false && managed) hostReferences++;
|
|
114
|
+
if (provider.enabled !== false && !managed && (!providerMediaBaseUrl(provider)
|
|
110
115
|
|| (provider.auth !== 'none' && !providerApiKey(provider).trim()))) incomplete++;
|
|
111
116
|
models += Array.isArray(provider.models) ? provider.models.length : 0;
|
|
112
117
|
}
|
|
113
118
|
if (incomplete) checks.push(result('providers', 'warn', `${incomplete} 个启用供应商缺少端点、所需凭据或结构无效(auth:none 不要求密钥)`, '在 Iris 工作台补全或停用对应供应商。'));
|
|
114
119
|
else if (!enabled) checks.push(result('providers', 'warn', '没有启用的供应商', '在 Iris 工作台启用至少一个供应商。'));
|
|
115
120
|
else checks.push(result('providers', 'ok', `${enabled} 个供应商已启用;凭据未输出`));
|
|
121
|
+
if (hostReferences) checks.push(result('provider-references', 'warn', `${hostReferences} 个 DSH 连接引用需由运行中宿主解析;离线未验证连接`,
|
|
122
|
+
'在 DSH 的 Iris 工作台检查原连接与功能池候选,无需在 Iris 重填宿主密钥。'));
|
|
116
123
|
const assignments = config.value.assignments || {};
|
|
117
124
|
const assignmentCount = Object.keys(assignments).length;
|
|
118
125
|
if (!models) checks.push(result('models', 'warn', '模型池为空或尚未发现模型', '在工作台对供应商执行“发现模型”,再核对能力标签。'));
|
|
@@ -378,7 +385,7 @@ export function hostDoctor(host, evidence = {}, options = {}) {
|
|
|
378
385
|
checks.push(result('dsh-version', 'ok', 'DSH ' + snapshot.host.version + ' 位于已验证支持范围'));
|
|
379
386
|
} else {
|
|
380
387
|
checks.push(result('dsh-version', 'error', 'DSH ' + snapshot.host.version + ' 超出已验证支持范围',
|
|
381
|
-
'当前支持 DSH ' + SUPPORTED_DSH_VERSIONS.join('、') + '
|
|
388
|
+
'当前支持 DSH ' + SUPPORTED_DSH_VERSIONS.join('、') + ';请核对所装 Iris 的兼容矩阵及 Node 要求,其他版本须另行验收。'));
|
|
382
389
|
}
|
|
383
390
|
}
|
|
384
391
|
|
package/lib/dsh-compatibility.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/** 已完成宿主接口验收的版本;manifest/Doctor/验收脚本共用同一边界。 */
|
|
4
|
-
export const SUPPORTED_DSH_VERSIONS = Object.freeze([
|
|
4
|
+
export const SUPPORTED_DSH_VERSIONS = Object.freeze([
|
|
5
|
+
'0.1.2-rc.1',
|
|
6
|
+
'0.1.5-rc.1', '0.1.5-rc.2', '0.1.5-rc.3',
|
|
7
|
+
'0.1.7-rc.1', '0.1.7-rc.2',
|
|
8
|
+
'0.2.0-rc.1', '0.2.0-rc.2',
|
|
9
|
+
'0.2.1-alpha.1', '0.2.1-alpha.2', '0.2.1-alpha.3', '0.2.1-alpha.4'
|
|
10
|
+
]);
|
|
5
11
|
export const DSH_SUPPORT_RANGE = SUPPORTED_DSH_VERSIONS.join(' || ');
|
|
6
12
|
|
|
7
13
|
export function isSupportedDshVersion(value) {
|
package/lib/dsh-core-adapter.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { runtimeProviders, assignments, irisHome, recordRateLimit, recordAvailability, refreshAutomaticDirectories } from './config.js';
|
|
6
|
+
import { withDshProviderConfig } from './dsh-provider-config.js';
|
|
6
7
|
import { createCommandService } from './command-service.js';
|
|
7
8
|
import { createCoreArtifact, inspectCoreArtifact, listCoreArtifacts, listCoreArtifactMetadata, readCoreArtifactBytes } from './core-artifacts.js';
|
|
8
9
|
import { createCoreRuntime } from './core-runtime.js';
|
|
@@ -23,9 +24,16 @@ const providerWatchers = new Map();
|
|
|
23
24
|
const DEFAULT_POLL_INTERVAL_MS = 2500;
|
|
24
25
|
const DEFAULT_MAX_WATCH_MS = 20 * 60 * 1000;
|
|
25
26
|
const POLL_ERROR_TOLERANCE = 5;
|
|
27
|
+
let providerHost = () => undefined;
|
|
28
|
+
|
|
29
|
+
/** The Host supplies a fresh optional connection port; no credentials are retained here. */
|
|
30
|
+
export function configureProviderHostForDsh(factory) {
|
|
31
|
+
providerHost = factory;
|
|
32
|
+
return () => { if (providerHost === factory) providerHost = () => undefined; };
|
|
33
|
+
}
|
|
26
34
|
|
|
27
35
|
function createConfiguredProviderAdapter(provider, options = {}) {
|
|
28
|
-
return createProviderAdapter(provider, { ...options, onRateLimit: recordRateLimit });
|
|
36
|
+
return createProviderAdapter(provider, { ...options, onRateLimit: recordRateLimit, onModelAvailability: recordAvailability });
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
export class DshCoreAdapterError extends Error {
|
|
@@ -290,8 +298,9 @@ export function retryProviderTaskForDsh(input = {}) {
|
|
|
290
298
|
}
|
|
291
299
|
return withWriter(async (runtime) => {
|
|
292
300
|
const commands = createCommandService(runtime, {
|
|
293
|
-
resolveTaskCandidates: ({ capability, modelRef }) => {
|
|
294
|
-
|
|
301
|
+
resolveTaskCandidates: ({ capability, modelRef }) => withDshProviderConfig(providerHost(), async () => {
|
|
302
|
+
if (!modelRef) await refreshAutomaticDirectories({ capability: { image: 'image-gen', video: 'video-gen', music: 'music-gen' }[capability] || capability, signal: input.signal });
|
|
303
|
+
const catalog = { providers: runtimeProviders(), assignments: assignments() };
|
|
295
304
|
const ref = modelRef || undefined;
|
|
296
305
|
const candidates = capability === 'video'
|
|
297
306
|
? videoCandidatesFromCatalog(catalog, ref)
|
|
@@ -308,7 +317,7 @@ export function retryProviderTaskForDsh(input = {}) {
|
|
|
308
317
|
selectionReason: route.selectionReason,
|
|
309
318
|
providerBinding: providerTaskBinding(route.provider)
|
|
310
319
|
}));
|
|
311
|
-
}
|
|
320
|
+
}, { signal: input.signal, fresh: true })
|
|
312
321
|
});
|
|
313
322
|
const result = await commands.execute('task.retry', {
|
|
314
323
|
task_id: taskId,
|
|
@@ -346,9 +355,11 @@ function observableProviderTask(task) {
|
|
|
346
355
|
&& task.phase !== 'terminal');
|
|
347
356
|
}
|
|
348
357
|
|
|
349
|
-
function configuredAdapterForTask(task) {
|
|
350
|
-
|
|
351
|
-
|
|
358
|
+
async function configuredAdapterForTask(task) {
|
|
359
|
+
return withDshProviderConfig(providerHost(), () => {
|
|
360
|
+
const provider = providerForTaskFromCatalog({ providers: runtimeProviders() }, task);
|
|
361
|
+
return createConfiguredProviderAdapter(provider, { modelId: parseModelRef(task.modelRef)?.modelId });
|
|
362
|
+
}, { fresh: true });
|
|
352
363
|
}
|
|
353
364
|
|
|
354
365
|
function stopProviderWatch(taskId, expectedState) {
|
|
@@ -371,7 +382,7 @@ export async function watchProviderTaskForDsh(input = {}) {
|
|
|
371
382
|
if (providerWatchers.has(taskId)) return false;
|
|
372
383
|
const initial = await inspectProviderTaskForDsh(taskId, { signal: input.signal });
|
|
373
384
|
if (!observableProviderTask(initial) || providerWatchers.has(taskId)) return false;
|
|
374
|
-
configuredAdapterForTask(initial); // binding/模型漂移在定时器和网络前失败
|
|
385
|
+
await configuredAdapterForTask(initial); // binding/模型漂移在定时器和网络前失败
|
|
375
386
|
|
|
376
387
|
const state = {
|
|
377
388
|
timer: null,
|
|
@@ -400,7 +411,7 @@ export async function watchProviderTaskForDsh(input = {}) {
|
|
|
400
411
|
}
|
|
401
412
|
let adapter;
|
|
402
413
|
try {
|
|
403
|
-
adapter = configuredAdapterForTask(before);
|
|
414
|
+
adapter = await configuredAdapterForTask(before);
|
|
404
415
|
} catch (error) {
|
|
405
416
|
console.warn('[iris] Core Task 无法按原 Provider binding 恢复观察:' + taskId
|
|
406
417
|
+ ' · ' + String(error?.code || error?.message || error));
|
|
@@ -450,7 +461,7 @@ export async function resumeProviderTaskWatchesForDsh(options = {}) {
|
|
|
450
461
|
for (const original of records) {
|
|
451
462
|
if (!WATCHABLE_CAPABILITIES.has(original.capability)) continue;
|
|
452
463
|
try {
|
|
453
|
-
if (observableProviderTask(original)) configuredAdapterForTask(original);
|
|
464
|
+
if (observableProviderTask(original)) await configuredAdapterForTask(original);
|
|
454
465
|
const needsRecovery = (original.phase === 'submitting' && original.acceptance === 'none')
|
|
455
466
|
|| original.cancelState === 'requested'
|
|
456
467
|
|| (original.outcome === 'succeeded' && ['pending', 'downloading'].includes(original.deliveryState))
|
package/lib/dsh-host-adapter.js
CHANGED
|
@@ -5,6 +5,7 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { defineHostAdapter } from './host-contract.js';
|
|
7
7
|
import { BrowserHtmlRenderer } from './render.js';
|
|
8
|
+
import { createDshProviderSource } from './dsh-provider-source.js';
|
|
8
9
|
|
|
9
10
|
export function detectDshVersion({ entry = process.argv[1] } = {}) {
|
|
10
11
|
if (!entry) return 'unknown';
|
|
@@ -93,37 +94,30 @@ function visionRouteError(message) {
|
|
|
93
94
|
return Object.assign(new Error(message), { code: 'IRIS_HOST_CAPABILITY_INCOMPATIBLE' });
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
function dshVisionPort(llm,
|
|
97
|
+
function dshVisionPort(llm, defaults) {
|
|
97
98
|
return Object.freeze({
|
|
98
99
|
id: 'dsh-global',
|
|
99
100
|
model: '全局视觉模型',
|
|
100
101
|
async analyze({ question, ref, signal }) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('已取消');
|
|
115
|
-
if (info?.provider !== provider || info?.id !== model || !Array.isArray(info.inputModalities)
|
|
116
|
-
|| !info.inputModalities.includes('image')) {
|
|
117
|
-
throw visionRouteError('DSH 默认模型未确认支持图片;请在 Iris 配置视觉模型或选择已声明图片输入的宿主模型');
|
|
118
|
-
}
|
|
119
|
-
route = { provider, model };
|
|
102
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('已取消');
|
|
103
|
+
const selection = defaults.currentSelection();
|
|
104
|
+
if (typeof selection?.provider !== 'string' || !selection.provider.trim()
|
|
105
|
+
|| typeof selection?.model !== 'string' || !selection.model.trim()) {
|
|
106
|
+
throw visionRouteError('DSH 默认模型没有明确路由;请在 Iris 配置视觉模型');
|
|
107
|
+
}
|
|
108
|
+
// 默认文本选择只有明确声明 image 输入时才可成为视觉候选。
|
|
109
|
+
const { provider, model } = selection;
|
|
110
|
+
const info = await llm.resolveModelInfo(provider, model, signal);
|
|
111
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('已取消');
|
|
112
|
+
if (info?.provider !== provider || info?.id !== model || !Array.isArray(info.inputModalities)
|
|
113
|
+
|| !info.inputModalities.includes('image')) {
|
|
114
|
+
throw visionRouteError('DSH 默认模型未确认支持图片;请在 Iris 配置视觉模型或选择已声明图片输入的宿主模型');
|
|
120
115
|
}
|
|
121
116
|
const chunks = [];
|
|
122
117
|
for await (const chunk of llm.stream({
|
|
123
118
|
sessionId: undefined,
|
|
124
|
-
provider
|
|
125
|
-
model
|
|
126
|
-
...route,
|
|
119
|
+
provider,
|
|
120
|
+
model,
|
|
127
121
|
signal,
|
|
128
122
|
messages: [{
|
|
129
123
|
role: 'user',
|
|
@@ -134,8 +128,8 @@ function dshVisionPort(llm, { defaults, requiresRoute }) {
|
|
|
134
128
|
}]
|
|
135
129
|
})) {
|
|
136
130
|
if (chunk?.type === 'error') throw visionRouteError('DSH 视觉调用失败;请检查宿主模型状态');
|
|
137
|
-
//
|
|
138
|
-
if (
|
|
131
|
+
// Runtime 也会把 Adapter 异常转换为 finish,不能接受失败前的部分文本。
|
|
132
|
+
if (chunk?.type === 'finish') {
|
|
139
133
|
if (chunk.reason?.kind === 'error') throw visionRouteError('DSH 视觉调用失败;请检查宿主模型状态');
|
|
140
134
|
if (chunk.reason?.kind === 'aborted') throw visionRouteError('DSH 视觉调用已取消');
|
|
141
135
|
}
|
|
@@ -208,6 +202,15 @@ export function createDshHostAdapter(ctx, { version = detectDshVersion() } = {})
|
|
|
208
202
|
} else unavailable.skills = unavailableFor(skills, 'DSH Skill registry 需要 register');
|
|
209
203
|
|
|
210
204
|
const llm = service(ctx, 'llm');
|
|
205
|
+
const settings = service(ctx, 'settings');
|
|
206
|
+
if (typeof llm?.listProviders === 'function' && typeof llm?.listConfigurableProviders === 'function'
|
|
207
|
+
&& typeof settings?.describe === 'function') {
|
|
208
|
+
ports.providerConnections = createDshProviderSource(() => ({
|
|
209
|
+
llm: service(ctx, 'llm'), settings: service(ctx, 'settings'), credentials: service(ctx, 'credentials')
|
|
210
|
+
}));
|
|
211
|
+
} else unavailable.providerConnections = {
|
|
212
|
+
kind: 'unavailable', reason: '当前 DSH 未提供供应商连接目录与设置接口,可继续手动添加供应商'
|
|
213
|
+
};
|
|
211
214
|
const defaults = service(ctx, 'agentDefaultModel');
|
|
212
215
|
if (llm && typeof llm.stream === 'function') {
|
|
213
216
|
ports.textModel = Object.freeze({
|
|
@@ -219,12 +222,12 @@ export function createDshHostAdapter(ctx, { version = detectDshVersion() } = {})
|
|
|
219
222
|
? () => defaults.currentSelection()
|
|
220
223
|
: () => null
|
|
221
224
|
});
|
|
222
|
-
|
|
223
|
-
if (
|
|
225
|
+
// 旧 SDK 同样提供精确路由;按接口探测,避免用宿主版本号误选历史路径。
|
|
226
|
+
if (defaults && typeof defaults.currentSelection === 'function'
|
|
224
227
|
&& typeof llm.resolveModelInfo === 'function') {
|
|
225
|
-
ports.visionModel = dshVisionPort(llm,
|
|
228
|
+
ports.visionModel = dshVisionPort(llm, defaults);
|
|
226
229
|
} else unavailable.visionModel = {
|
|
227
|
-
kind: 'unavailable', reason: 'DSH
|
|
230
|
+
kind: 'unavailable', reason: 'DSH 视觉桥接需要默认模型选择和精确图片能力元数据'
|
|
228
231
|
};
|
|
229
232
|
} else {
|
|
230
233
|
unavailable.textModel = unavailableFor(llm, 'DSH llm 需要 stream');
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { allProviders } from './config.js';
|
|
2
|
+
import { hasProviderConfigScope, withProviderConfigScope } from './provider-config-scope.js';
|
|
3
|
+
import { DshProviderSourceError } from './dsh-provider-source.js';
|
|
4
|
+
|
|
5
|
+
export function normalizeDshProvider(value) {
|
|
6
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
7
|
+
|| typeof value.provider !== 'string' || !value.provider.trim()
|
|
8
|
+
|| typeof value.settingsNs !== 'string' || !value.settingsNs.trim()
|
|
9
|
+
|| Object.keys(value).some(key => !['provider', 'settingsNs'].includes(key))) {
|
|
10
|
+
throw Object.assign(new Error('DSH 连接引用需要 provider 和 settingsNs'), { code: 'IRIS_CONFIG_INPUT_INVALID' });
|
|
11
|
+
}
|
|
12
|
+
return { provider: value.provider.trim(), settingsNs: value.settingsNs.trim() };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Existing request handlers see resolved copies; the persistent store stays raw. */
|
|
16
|
+
export async function withDshProviderConfig(host, callback, { signal, fresh = false } = {}) {
|
|
17
|
+
if (!fresh && hasProviderConfigScope()) return callback();
|
|
18
|
+
const providers = allProviders();
|
|
19
|
+
if (!providers.some(provider => provider.dshProvider)) return callback();
|
|
20
|
+
const resolved = await Promise.all(providers.map(async provider => {
|
|
21
|
+
if (!provider.dshProvider || provider.enabled === false) return provider;
|
|
22
|
+
try {
|
|
23
|
+
if (typeof host?.ports?.providerConnections?.resolve !== 'function') {
|
|
24
|
+
throw new DshProviderSourceError('当前宿主无法解析 DSH 连接,请检查原连接或改用手动供应商');
|
|
25
|
+
}
|
|
26
|
+
const value = await host.ports.providerConnections.resolve(provider.dshProvider, { signal });
|
|
27
|
+
return { ...provider, baseUrl: value.baseUrl, apiKey: value.apiKey };
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (!(error instanceof DshProviderSourceError)) throw error;
|
|
30
|
+
return { ...provider, enabled: false, connectionError: error.message };
|
|
31
|
+
}
|
|
32
|
+
}));
|
|
33
|
+
signal?.throwIfAborted();
|
|
34
|
+
return withProviderConfigScope(resolved, callback);
|
|
35
|
+
}
|