@mokuyoaxis/dsh-iris 0.2.5 → 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 +27 -1
- package/README.en.md +27 -19
- package/README.md +28 -18
- package/bin/dsh-iris.js +29 -12
- package/docs/AGENT_QUICKSTART.md +7 -7
- package/docs/CLI_MANAGEMENT.md +5 -1
- package/docs/DSH_COMPATIBILITY.md +8 -4
- package/docs/DSH_PROVIDER_CONNECTIONS.md +34 -0
- 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 +7 -2
- package/docs/HOST_DOCTOR.md +2 -2
- package/docs/MUSIC.md +2 -2
- package/docs/PROVIDER_HEALTH.md +26 -2
- package/docs/ROADMAP.md +5 -3
- package/docs/SECURITY.md +12 -2
- package/docs/VISION_CLI.md +2 -2
- 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 +8 -1
- package/lib/dsh-core-adapter.js +22 -11
- package/lib/dsh-host-adapter.js +10 -0
- 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 +2 -2
- package/user_guide.md +25 -19
package/lib/models.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { CAPABILITIES, configuredModelCapabilities } from './capability.js';
|
|
15
15
|
import { modelRateLimit } from './provider-health.js';
|
|
16
|
+
import { supportsModelCapability } from './provider-protocol.js';
|
|
17
|
+
import { currentModelDirectory, modelDirectoryEntry, modelDirectoryStatus, modelUnavailable } from './model-directory.js';
|
|
16
18
|
|
|
17
19
|
/** 能力 → provider 上的模型字段(保持向后兼容的返回形状) */
|
|
18
20
|
export const CAP_FIELD = {
|
|
@@ -108,7 +110,11 @@ export function providerModels(p) {
|
|
|
108
110
|
const push = (id, caps, extra) => {
|
|
109
111
|
const name = String(id || '').trim();
|
|
110
112
|
if (!name || out.some((m) => m.id === name)) return;
|
|
113
|
+
const unavailableCapabilities = caps.filter(capability => modelUnavailable(p, name, capability, { allowUnresolvedConnections: true }));
|
|
114
|
+
const catalogStatus = modelDirectoryStatus(p, name);
|
|
111
115
|
out.push({ id: name, providerId: p.id, ref: modelRef(p.id, name), capabilities: [...caps], ...(extra || {}),
|
|
116
|
+
...(catalogStatus !== 'unknown' ? { catalogStatus } : {}),
|
|
117
|
+
...(unavailableCapabilities.length ? { unavailableCapabilities } : {}),
|
|
112
118
|
...(modelRateLimit(p, name) ? { rateLimited: true } : {}) });
|
|
113
119
|
};
|
|
114
120
|
// ① 显式 models 数组(带可选 capabilities 覆盖 + verified/source 透传)
|
|
@@ -119,7 +125,8 @@ export function providerModels(p) {
|
|
|
119
125
|
for (const m of p.models) {
|
|
120
126
|
const id = typeof m === 'string' ? m : m && m.id;
|
|
121
127
|
// 没有明确能力的名称保留为未标注模型,不进入候选链。
|
|
122
|
-
const
|
|
128
|
+
const metadata = m?.source === 'discovered' && modelDirectoryEntry(p, id, { allowUnresolvedConnections: true });
|
|
129
|
+
const caps = metadata ? metadata.capabilities : configuredModelCapabilities(p, m);
|
|
123
130
|
const extra = typeof m === 'object' && m ? { verified: m.verified, source: m.source,
|
|
124
131
|
...(m.imageProtocol !== undefined ? { imageProtocol: m.imageProtocol } : {}),
|
|
125
132
|
...(m.musicProtocol !== undefined ? { musicProtocol: m.musicProtocol } : {}),
|
|
@@ -136,6 +143,14 @@ export function providerModels(p) {
|
|
|
136
143
|
return out;
|
|
137
144
|
}
|
|
138
145
|
|
|
146
|
+
/** 管理界面附上新目录条目,未登记的发现项不会因此进入执行范围。 */
|
|
147
|
+
export function providerModelsWithDirectory(provider) {
|
|
148
|
+
const configured = providerModels(provider);
|
|
149
|
+
const directory = currentModelDirectory(provider, { allowUnresolvedConnections: true });
|
|
150
|
+
return configured.concat((directory?.models || []).filter(model => !configured.some(entry => entry.id === model.id))
|
|
151
|
+
.map(model => ({ ...model, providerId: provider.id, ref: modelRef(provider.id, model.id), source: 'discovered', catalogStatus: 'listed', directoryOnly: true })));
|
|
152
|
+
}
|
|
153
|
+
|
|
139
154
|
/** 只解析用户配置中的模型;裸 ID 与复合引用共享能力校验。 */
|
|
140
155
|
export function resolveModelRef(provider, capability, requested = '') {
|
|
141
156
|
const pool = providerModels(provider).filter((m) => m.capabilities.includes(capability));
|
|
@@ -162,19 +177,67 @@ export function resolvePoolModel(pool, raw, capability) {
|
|
|
162
177
|
&& m.capabilities.includes(capability)) || null;
|
|
163
178
|
}
|
|
164
179
|
|
|
165
|
-
/**
|
|
166
|
-
export function
|
|
180
|
+
/** Legacy assignments stay manual/disabled; only an explicit policy enables a pool. */
|
|
181
|
+
export function functionPolicy(value, capability) {
|
|
182
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && Object.hasOwn(value, 'mode')) {
|
|
183
|
+
if (!['auto', 'manual', 'disabled'].includes(value.mode)
|
|
184
|
+
|| Object.keys(value).some(key => !['mode', 'models', 'hostVision'].includes(key))
|
|
185
|
+
|| (value.models !== undefined && (!Array.isArray(value.models) || !value.models.every(ref => typeof ref === 'string')))
|
|
186
|
+
|| (value.hostVision !== undefined && (typeof value.hostVision !== 'boolean'
|
|
187
|
+
|| (value.hostVision && capability !== CAPABILITIES.VISION)))) {
|
|
188
|
+
throw Object.assign(new TypeError('功能策略需要 auto、manual 或 disabled;models 为引用数组,hostVision 仅用于视觉'), {
|
|
189
|
+
code: 'IRIS_FUNCTION_POLICY_INVALID'
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return { mode: value.mode, models: value.models || [], hostVision: value.hostVision === true };
|
|
193
|
+
}
|
|
194
|
+
return { mode: value ? 'manual' : 'disabled', models: Array.isArray(value) ? value : value ? [value] : [], hostVision: false };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Preserve legacy names/object references when a user explicitly changes policy. */
|
|
198
|
+
export function savedAssignmentRefs(pool, capability, values) {
|
|
199
|
+
const refs = values.map(raw => {
|
|
200
|
+
const resolved = resolvePoolModel(pool, raw, capability);
|
|
201
|
+
if (resolved) return resolved.ref;
|
|
202
|
+
if (typeof raw === 'string') return raw;
|
|
203
|
+
const id = String(raw?.modelId || raw?.id || '');
|
|
204
|
+
return id && raw?.providerId ? modelRef(raw.providerId, id) : id;
|
|
205
|
+
});
|
|
206
|
+
return [...new Set(refs.filter(Boolean))];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Automatic pools reject known protocol gaps before constructing any adapter. */
|
|
210
|
+
export function functionModelPool(providers, capability, assignment, { includeHostVision = false, allowUnresolvedConnections = false } = {}) {
|
|
211
|
+
const policy = functionPolicy(assignment, capability);
|
|
212
|
+
return modelPool(providers, { includeHostVision }).filter(model => {
|
|
213
|
+
if (!model.capabilities.includes(capability)) return false;
|
|
214
|
+
if (policy.mode !== 'auto' || model.ref === HOST_VISION_REF) return true;
|
|
215
|
+
const provider = providers.find(provider => provider.id === model.providerId);
|
|
216
|
+
return supportsModelCapability(provider, model.id, capability, {
|
|
217
|
+
checkEndpoint: !allowUnresolvedConnections || !provider?.dshProvider
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Shared scope and stable ordering; discovery alone never expands an automatic pool. */
|
|
223
|
+
export function orderedModels(pool, capability, assignments, { includeRateLimited = false, includeUnavailable = false } = {}) {
|
|
167
224
|
const ordered = [];
|
|
168
225
|
const seen = new Set();
|
|
169
226
|
const add = (model) => {
|
|
170
227
|
if (model && (includeRateLimited || !model.rateLimited)
|
|
228
|
+
&& (includeUnavailable || !model.unavailableCapabilities?.includes(capability))
|
|
171
229
|
&& model.capabilities.includes(capability) && !seen.has(model.ref)) {
|
|
172
230
|
seen.add(model.ref);
|
|
173
231
|
ordered.push(model);
|
|
174
232
|
}
|
|
175
233
|
};
|
|
176
|
-
|
|
177
|
-
|
|
234
|
+
const policy = functionPolicy(assignments, capability);
|
|
235
|
+
if (policy.mode === 'auto') {
|
|
236
|
+
for (const model of pool) {
|
|
237
|
+
if (model.source !== 'discovered' && (model.ref !== HOST_VISION_REF || policy.hostVision)) add(model);
|
|
238
|
+
}
|
|
239
|
+
} else if (policy.mode === 'manual') {
|
|
240
|
+
for (const raw of policy.models) add(resolvePoolModel(pool, raw, capability));
|
|
178
241
|
}
|
|
179
242
|
return ordered;
|
|
180
243
|
}
|
|
@@ -189,7 +252,9 @@ export function selectionReasonForModel(pool, capability, assignments, modelOrRe
|
|
|
189
252
|
modelOrRef && typeof modelOrRef === 'object' ? modelOrRef.ref : modelOrRef,
|
|
190
253
|
capability);
|
|
191
254
|
if (!selected) return null;
|
|
192
|
-
|
|
255
|
+
const policy = functionPolicy(assignments, capability);
|
|
256
|
+
if (policy.mode === 'auto') return 'pool';
|
|
257
|
+
for (const raw of policy.models) {
|
|
193
258
|
const assigned = resolvePoolModel(pool, raw, capability);
|
|
194
259
|
if (assigned?.ref === selected.ref) return 'assignment';
|
|
195
260
|
}
|
package/lib/ocr-model-routing.js
CHANGED
|
@@ -8,13 +8,13 @@ import { waitForModelWork } from './model-call-runtime.js';
|
|
|
8
8
|
import { createVisionInputObserver } from './vision-image-input.js';
|
|
9
9
|
|
|
10
10
|
export async function runOcrRequest(host, { providers = [], model, image, prepareImage, signal,
|
|
11
|
-
chunkHeight, overlap, maxDimension, maxInvocations, budget = OCR_BUDGET, onRateLimit }) {
|
|
11
|
+
chunkHeight, overlap, maxDimension, maxInvocations, budget = OCR_BUDGET, onRateLimit, onModelAvailability }) {
|
|
12
12
|
const settings = normalizeOcrSettings({ chunkHeight, overlap, maxDimension, maxInvocations });
|
|
13
13
|
const operation = createModelOperation({ signal, budget: { timeoutMs: budget.timeoutMs, maxInvocations: settings.maxInvocations } });
|
|
14
14
|
try {
|
|
15
15
|
if (operation.signal.aborted) throw new ModelPortError('IRIS_MODEL_ABORTED');
|
|
16
16
|
const inputs = new Map();
|
|
17
|
-
const candidates = buildVisionModelCandidates(host, { providers, model, onRateLimit, prepareImages: true,
|
|
17
|
+
const candidates = buildVisionModelCandidates(host, { providers, model, onRateLimit, onModelAvailability, prepareImages: true,
|
|
18
18
|
prepareImageRequest: createVisionInputObserver(inputs) });
|
|
19
19
|
normalizeModelCall(modelPortSnapshot(candidates[0].port), { prompt: 'OCR',
|
|
20
20
|
image: { bytes: new Uint8Array([0]), mediaType: 'image/png' } }, { budget });
|
package/lib/provider-adapters.js
CHANGED
|
@@ -10,8 +10,9 @@ import * as defaultTransport from './adapters.js';
|
|
|
10
10
|
import { ProviderContractError, providerErrorRecord } from './provider-contract.js';
|
|
11
11
|
import { writePrivateFile } from './private-storage.js';
|
|
12
12
|
import { defineProviderAdapter } from './provider-adapter.js';
|
|
13
|
-
import { selectMediaProtocol, providerApiKey, providerMediaBaseUrl, unsupportedProtocolError, supportsImageEdit } from './provider-protocol.js';
|
|
14
|
-
import { modelRateLimit, reportModelRateLimit, isFreeQuotaExhausted } from './provider-health.js';
|
|
13
|
+
import { selectMediaProtocol, providerApiKey, providerMediaBaseUrl, unsupportedProtocolError, supportsImageEdit, dashscopeTtsInputIssue } from './provider-protocol.js';
|
|
14
|
+
import { modelRateLimit, reportModelRateLimit, isFreeQuotaExhausted, recordModelAvailability } from './provider-health.js';
|
|
15
|
+
import { modelUnavailable, modelUnavailableReason, modelAvailabilityBinding } from './model-directory.js';
|
|
15
16
|
import { prepareVisionImage, visionInputLimits } from './vision-image-input.js';
|
|
16
17
|
|
|
17
18
|
const AMBIGUOUS_4XX = new Set([408, 409, 425, 499]);
|
|
@@ -74,6 +75,15 @@ export function mapProviderAdapterError(error, context = {}) {
|
|
|
74
75
|
});
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
/** Remove the operation's exact key before generic redaction can truncate it. */
|
|
79
|
+
function mapProviderErrorWithKey(error, context, key) {
|
|
80
|
+
const scrub = value => typeof value === 'string' && key ? value.split(key).join('[REDACTED]') : value;
|
|
81
|
+
return providerErrorRecord(mapProviderAdapterError(error, context), {
|
|
82
|
+
safeMessage: scrub(String(error?.safeMessage || error?.message || error)),
|
|
83
|
+
providerCode: scrub(error?.providerCode ?? error?.code)
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
77
87
|
function unsupportedCapability(capability, protocol) {
|
|
78
88
|
return new ProviderContractError(
|
|
79
89
|
'协议 ' + protocol + ' 不支持任务能力 ' + String(capability || ''),
|
|
@@ -174,6 +184,10 @@ function operationsForDashScope(provider, transport) {
|
|
|
174
184
|
return { kind: 'accepted', remoteTaskId };
|
|
175
185
|
}
|
|
176
186
|
if (request.capability === 'tts') {
|
|
187
|
+
const issue = dashscopeTtsInputIssue(request.model, input.voice);
|
|
188
|
+
if (issue) throw new ProviderContractError(issue, {
|
|
189
|
+
stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
|
|
190
|
+
});
|
|
177
191
|
const value = await transport.synthesizeTts({
|
|
178
192
|
...common, text: input.text, voice: input.voice
|
|
179
193
|
});
|
|
@@ -197,9 +211,9 @@ function operationsForDashScope(provider, transport) {
|
|
|
197
211
|
if (!result.ok) {
|
|
198
212
|
return {
|
|
199
213
|
kind: pollFailureKind(result.status),
|
|
200
|
-
error:
|
|
214
|
+
error: mapProviderErrorWithKey(result.message || '转写任务失败', {
|
|
201
215
|
stage: 'poll', acceptance: 'accepted'
|
|
202
|
-
})
|
|
216
|
+
}, key)
|
|
203
217
|
};
|
|
204
218
|
}
|
|
205
219
|
// E3:转写正文物化为 text/plain Artifact(inline-base64,不再依赖签名 URL 生命周期)。
|
|
@@ -216,9 +230,9 @@ function operationsForDashScope(provider, transport) {
|
|
|
216
230
|
if (!result.ok) {
|
|
217
231
|
return {
|
|
218
232
|
kind: pollFailureKind(result.status),
|
|
219
|
-
error:
|
|
233
|
+
error: mapProviderErrorWithKey(result.message || '生成任务失败', {
|
|
220
234
|
stage: 'poll', acceptance: 'accepted'
|
|
221
|
-
})
|
|
235
|
+
}, key)
|
|
222
236
|
};
|
|
223
237
|
}
|
|
224
238
|
return {
|
|
@@ -253,7 +267,7 @@ function operationsForDashScope(provider, transport) {
|
|
|
253
267
|
};
|
|
254
268
|
},
|
|
255
269
|
|
|
256
|
-
mapError:
|
|
270
|
+
mapError: (error, context) => mapProviderErrorWithKey(error, context, key)
|
|
257
271
|
};
|
|
258
272
|
}
|
|
259
273
|
|
|
@@ -336,7 +350,7 @@ function operationsForOpenAiImages(provider, transport, protocol = 'openai-image
|
|
|
336
350
|
};
|
|
337
351
|
},
|
|
338
352
|
|
|
339
|
-
mapError:
|
|
353
|
+
mapError: (error, context) => mapProviderErrorWithKey(error, context, key)
|
|
340
354
|
};
|
|
341
355
|
}
|
|
342
356
|
|
|
@@ -397,7 +411,7 @@ export function createGeminiWebMusicProviderAdapter(provider, { transport = defa
|
|
|
397
411
|
return { kind: 'completed', artifacts };
|
|
398
412
|
},
|
|
399
413
|
async download({ artifact, targetPath }) { return writeInlineArtifact(artifact, targetPath); },
|
|
400
|
-
mapError:
|
|
414
|
+
mapError: (error, context) => mapProviderErrorWithKey(error, context, key)
|
|
401
415
|
},
|
|
402
416
|
unsupported: { poll: '网关 Music 没有客户端可恢复的远端任务ID', cancel: '网关 Music 不提供远端取消' },
|
|
403
417
|
unsupportedPreparation: 'Music 描述输入不需要上传'
|
|
@@ -431,6 +445,23 @@ export function createConfiguredProviderAdapter(provider, options = {}) {
|
|
|
431
445
|
retryAfterMs: error.retryAfterMs,
|
|
432
446
|
source: options.allowRateLimited ? 'probe' : 'task'
|
|
433
447
|
}, options.onRateLimit, request.capability);
|
|
448
|
+
const availability = result => {
|
|
449
|
+
if (name !== 'submit') return;
|
|
450
|
+
const event = { ok: result.ok === true, category: result.category,
|
|
451
|
+
httpStatus: result.httpStatus ?? result.status, providerCode: result.providerCode || result.code,
|
|
452
|
+
note: result.safeMessage || result.message,
|
|
453
|
+
stage: 'submit', source: options.allowRateLimited ? 'probe' : 'task',
|
|
454
|
+
binding: modelAvailabilityBinding(provider, modelId, capability) };
|
|
455
|
+
if (!recordModelAvailability(provider, modelId, request.capability, event)) return;
|
|
456
|
+
try { options.onModelAvailability?.({ providerId: provider.id, modelId, capability: request.capability, ...event }); }
|
|
457
|
+
catch (_) { console.warn('[iris] 模型可用性未能持久化,请检查配置写入权限'); }
|
|
458
|
+
};
|
|
459
|
+
const capability = { image: 'image-gen', video: 'video-gen', music: 'music-gen' }[request.capability] || request.capability;
|
|
460
|
+
if (name === 'submit' && !options.allowRateLimited && modelUnavailable(provider, modelId, capability)) {
|
|
461
|
+
throw new ProviderContractError(modelUnavailableReason(), {
|
|
462
|
+
stage: 'validate', category: 'invalid_request', acceptance: 'not_accepted'
|
|
463
|
+
});
|
|
464
|
+
}
|
|
434
465
|
if (name === 'submit' && !options.allowRateLimited && modelRateLimit(provider, modelId)) {
|
|
435
466
|
const blocked = modelRateLimit(provider, modelId);
|
|
436
467
|
throw new ProviderContractError(blocked.until ? '该模型正在限流冷却,请稍后使用' : '该模型额度或预算耗尽,请手动实测成功后再使用', {
|
|
@@ -440,9 +471,11 @@ export function createConfiguredProviderAdapter(provider, options = {}) {
|
|
|
440
471
|
try {
|
|
441
472
|
const result = await run(request);
|
|
442
473
|
if (result.error) report(result.error);
|
|
474
|
+
availability(result.error || { ok: ['completed', 'accepted'].includes(result.kind) });
|
|
443
475
|
return result;
|
|
444
476
|
} catch (error) {
|
|
445
477
|
report(error);
|
|
478
|
+
availability(error);
|
|
446
479
|
throw error;
|
|
447
480
|
}
|
|
448
481
|
};
|
package/lib/provider-catalog.js
CHANGED
|
@@ -4,11 +4,12 @@ import crypto from 'node:crypto';
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { CAPABILITIES } from './capability.js';
|
|
7
|
-
import { modelPool, orderedModels, providerModels, parseModelRef, resolveModelRef, resolvePoolModel, selectionReasonForModel } from './models.js';
|
|
7
|
+
import { modelPool, functionModelPool, functionPolicy, orderedModels, providerModels, parseModelRef, resolveModelRef, resolvePoolModel, selectionReasonForModel } from './models.js';
|
|
8
8
|
import { dashscopeApiBase, selectMediaProtocol, providerForImageModel, providerForMusicModel, providerMediaBaseUrl, isConfiguredProvider } from './provider-protocol.js';
|
|
9
9
|
import { redactProviderMessage } from './provider-contract.js';
|
|
10
10
|
import { modelRateLimit } from './provider-health.js';
|
|
11
11
|
import { normalizeVisionInputLimits } from './vision-image-input.js';
|
|
12
|
+
import { modelDirectorySummary, modelUnavailableReason, validateModelDirectory } from './model-directory.js';
|
|
12
13
|
|
|
13
14
|
export class ProviderCatalogError extends Error {
|
|
14
15
|
constructor(code, message) {
|
|
@@ -49,6 +50,8 @@ export function loadProviderCatalog(configPath) {
|
|
|
49
50
|
|| Array.isArray(parsed.assignments)))) {
|
|
50
51
|
fail('IRIS_PROVIDER_CONFIG_INVALID', 'provider-config 结构无效');
|
|
51
52
|
}
|
|
53
|
+
for (const [capability, assignment] of Object.entries(parsed.assignments || {})) functionPolicy(assignment, capability);
|
|
54
|
+
for (const provider of parsed.providers) validateModelDirectory(provider.modelDirectory);
|
|
52
55
|
return parsed;
|
|
53
56
|
}
|
|
54
57
|
|
|
@@ -58,13 +61,13 @@ const CANDIDATE_CAP_LABEL = Object.freeze({
|
|
|
58
61
|
[CAPABILITIES.MUSIC]: '音乐'
|
|
59
62
|
});
|
|
60
63
|
|
|
61
|
-
/** 与 DSH
|
|
64
|
+
/** 与 DSH 共用功能策略;CLI 不附加宿主资源。 */
|
|
62
65
|
function candidatesFromCatalog(catalog, requestedModelRef, capability) {
|
|
63
66
|
const label = CANDIDATE_CAP_LABEL[capability];
|
|
64
67
|
if (!label) fail('IRIS_PROVIDER_TASK_OBSERVE_UNSUPPORTED', '当前 Headless 候选链只开放图片、视频、语音与转写能力');
|
|
65
68
|
const providers = catalog.providers.filter(isConfiguredProvider);
|
|
66
|
-
const configuredPool =
|
|
67
|
-
const pool = orderedModels(configuredPool, capability, catalog.assignments?.[capability], { includeRateLimited: true });
|
|
69
|
+
const configuredPool = functionModelPool(providers, capability, catalog.assignments?.[capability]);
|
|
70
|
+
const pool = orderedModels(configuredPool, capability, catalog.assignments?.[capability], { includeRateLimited: true, includeUnavailable: true });
|
|
68
71
|
const requested = String(requestedModelRef || '').trim();
|
|
69
72
|
if (requested) {
|
|
70
73
|
const parsed = parseModelRef(requested);
|
|
@@ -72,6 +75,7 @@ function candidatesFromCatalog(catalog, requestedModelRef, capability) {
|
|
|
72
75
|
const exact = resolvePoolModel(pool, requested, capability);
|
|
73
76
|
if (!exact) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `指定模型未分配给${label}功能、未启用或没有所需能力`);
|
|
74
77
|
const provider = providers.find((item) => item.id === exact.providerId);
|
|
78
|
+
if (exact.unavailableCapabilities?.includes(capability)) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', modelUnavailableReason());
|
|
75
79
|
if (exact.rateLimited) fail('IRIS_PROVIDER_MODEL_RATE_LIMITED', modelRateLimit(provider, exact.id)?.until
|
|
76
80
|
? '指定模型正在限流冷却,请稍后使用或手动实测'
|
|
77
81
|
: '指定模型额度或预算耗尽,请手动实测成功后再使用');
|
|
@@ -81,8 +85,8 @@ function candidatesFromCatalog(catalog, requestedModelRef, capability) {
|
|
|
81
85
|
})]);
|
|
82
86
|
}
|
|
83
87
|
const assignment = catalog.assignments?.[capability];
|
|
84
|
-
const ordered = pool.filter(model => !model.rateLimited);
|
|
85
|
-
if (!ordered.length) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE',
|
|
88
|
+
const ordered = pool.filter(model => !model.rateLimited && !model.unavailableCapabilities?.includes(capability));
|
|
89
|
+
if (!ordered.length) fail('IRIS_PROVIDER_MODEL_UNAVAILABLE', `请检查${label}功能的启用方式、已确认模型、连接和协议;当前没有可用候选`);
|
|
86
90
|
return Object.freeze(ordered.map((item) => Object.freeze({
|
|
87
91
|
provider: providers.find((provider) => provider.id === item.providerId),
|
|
88
92
|
model: item.id,
|
|
@@ -133,20 +137,21 @@ export function providerCatalogSnapshot(catalog) {
|
|
|
133
137
|
mediaProtocol: safeLabel(selection.mediaProtocol), protocolInferred: selection.protocolInferred,
|
|
134
138
|
...(provider.visionInput ? { visionInput: normalizeVisionInputLimits(provider.visionInput) } : {}),
|
|
135
139
|
capabilities: Object.values(CAPABILITIES).filter((capability) => models.some((model) => model.capabilities.includes(capability))),
|
|
136
|
-
modelCount: models.length
|
|
140
|
+
modelCount: models.length, directory: modelDirectorySummary(provider)
|
|
137
141
|
};
|
|
138
142
|
})
|
|
139
143
|
};
|
|
140
144
|
}
|
|
141
145
|
|
|
142
146
|
export function catalogCapabilitySnapshot(catalog) {
|
|
143
|
-
const pool = modelPool(catalog.providers.filter(isConfiguredProvider));
|
|
144
147
|
return {
|
|
145
148
|
schemaVersion: 1,
|
|
146
149
|
source: 'configuration',
|
|
147
150
|
capabilities: Object.values(CAPABILITIES).map((capability) => {
|
|
151
|
+
const policy = functionPolicy(catalog.assignments?.[capability], capability);
|
|
152
|
+
const pool = functionModelPool(catalog.providers.filter(isConfiguredProvider), capability, catalog.assignments?.[capability]);
|
|
148
153
|
const candidates = orderedModels(pool, capability, catalog.assignments?.[capability]).map((model) => model.ref);
|
|
149
|
-
return { capability, candidates, ...(candidates.length ? {} : { gap: 'no_configured_model' }) };
|
|
154
|
+
return { capability, mode: policy.mode, candidates, ...(candidates.length ? {} : { gap: 'no_configured_model' }) };
|
|
150
155
|
})
|
|
151
156
|
};
|
|
152
157
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
|
|
3
|
+
// Resolved credentials live only in one operation's asynchronous context.
|
|
4
|
+
const scope = new AsyncLocalStorage();
|
|
5
|
+
|
|
6
|
+
export function scopedProvider(provider) {
|
|
7
|
+
return (provider.dshProvider && scope.getStore()?.get(provider.id)) || provider;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function hasProviderConfigScope() {
|
|
11
|
+
return scope.getStore() !== undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function withProviderConfigScope(providers, callback) {
|
|
15
|
+
return scope.run(new Map(providers.map(provider => [provider.id, provider])), callback);
|
|
16
|
+
}
|
|
@@ -3,14 +3,16 @@ import path from 'node:path';
|
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import { loadProviderCatalog, providerCatalogSnapshot, catalogCapabilitySnapshot } from './provider-catalog.js';
|
|
5
5
|
import { isKnownCapability } from './capability.js';
|
|
6
|
-
import { providerModels, modelPool, parseModelRef, HOST_VISION_REF, capabilitiesOfDiscoveredModel, hasConfiguredModelCapabilities, resolvePoolModel } from './models.js';
|
|
6
|
+
import { providerModels, modelPool, functionPolicy, savedAssignmentRefs, parseModelRef, HOST_VISION_REF, capabilitiesOfDiscoveredModel, hasConfiguredModelCapabilities, resolvePoolModel } from './models.js';
|
|
7
7
|
import { atomicWritePrivate, privateSibling, ensurePrivateDir } from './private-storage.js';
|
|
8
8
|
import { createConfiguredProviderAdapter } from './provider-adapters.js';
|
|
9
|
-
import { selectImageProtocol, normalizeImageProtocol, normalizeMusicProtocol } from './provider-protocol.js';
|
|
9
|
+
import { selectImageProtocol, normalizeImageProtocol, normalizeMusicProtocol, isConfiguredProvider } from './provider-protocol.js';
|
|
10
10
|
import { invokeProviderOperation } from './provider-adapter.js';
|
|
11
11
|
import { redactProviderMessage } from './provider-contract.js';
|
|
12
|
-
import { modelRateLimit, recordModelRateLimit, clearModelRateLimit, clearModelCapabilityHealth, isDefinitiveHealthFailure } from './provider-health.js';
|
|
12
|
+
import { modelRateLimit, recordModelRateLimit, clearModelRateLimit, clearModelCapabilityHealth, isDefinitiveHealthFailure, recordModelAvailability, resetHealthOnCriticalChange } from './provider-health.js';
|
|
13
13
|
import { normalizeVisionInputLimits, visionInputLimits } from './vision-image-input.js';
|
|
14
|
+
import { currentModelDirectory, modelDirectoryBinding, modelAvailabilityBinding } from './model-directory.js';
|
|
15
|
+
import { refreshModelDirectory } from './model-directory-service.js';
|
|
14
16
|
|
|
15
17
|
const fail = message => { throw Object.assign(new Error(message), { code: 'IRIS_CONFIG_INPUT_INVALID' }); };
|
|
16
18
|
const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
@@ -24,14 +26,18 @@ const caps = value => {
|
|
|
24
26
|
function assignmentSnapshot(catalog) {
|
|
25
27
|
const pool = modelPool(catalog.providers, { includeHostVision: true });
|
|
26
28
|
return Object.fromEntries(Object.entries(catalog.assignments || {}).map(([capability, value]) => {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
+
const policy = functionPolicy(value, capability);
|
|
30
|
+
const refs = policy.models.map(ref => resolvePoolModel(pool, ref, capability));
|
|
31
|
+
return [safe(capability), { mode: policy.mode, hostVision: policy.hostVision,
|
|
32
|
+
model_refs: refs.filter(Boolean).map(model => safe(model.ref)), invalidRefs: refs.filter(ref => !ref).length }];
|
|
29
33
|
}));
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export function configSnapshot(catalog) {
|
|
33
37
|
return { ...providerCatalogSnapshot(catalog), models: catalog.providers.flatMap(provider => providerModels(provider).map(model => ({
|
|
34
38
|
ref: safe(model.ref), providerId: safe(provider.id), id: safe(model.id), capabilities: model.capabilities,
|
|
39
|
+
...(model.catalogStatus ? { catalogStatus: model.catalogStatus } : {}),
|
|
40
|
+
...(model.unavailableCapabilities ? { unavailableCapabilities: model.unavailableCapabilities } : {}),
|
|
35
41
|
...(model.imageProtocol !== undefined ? { imageProtocol: model.imageProtocol } : {}),
|
|
36
42
|
...(model.musicProtocol !== undefined ? { musicProtocol: model.musicProtocol } : {}),
|
|
37
43
|
...(model.visionInput !== undefined ? { visionInput: model.visionInput } : {}),
|
|
@@ -73,7 +79,8 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
|
|
|
73
79
|
}
|
|
74
80
|
for (const [capability, assignment] of Object.entries(catalog.assignments || {})) {
|
|
75
81
|
if (!isKnownCapability(capability)) issues.push('unknown_assignment_capability');
|
|
76
|
-
|
|
82
|
+
const policy = functionPolicy(assignment, capability);
|
|
83
|
+
if (policy.mode === 'manual') for (const ref of policy.models) if (!resolvePoolModel(pool, ref, capability)) issues.push('unavailable_assignment_model');
|
|
77
84
|
}
|
|
78
85
|
return { valid: issues.length === 0, issues: [...new Set(issues)] };
|
|
79
86
|
}
|
|
@@ -116,6 +123,7 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
|
|
|
116
123
|
clearModelCapabilityHealth(updated, model.id, 'image-gen');
|
|
117
124
|
}
|
|
118
125
|
}
|
|
126
|
+
resetHealthOnCriticalChange(provider, updated, value);
|
|
119
127
|
}
|
|
120
128
|
if (provider) catalog.providers[catalog.providers.indexOf(provider)] = updated;
|
|
121
129
|
else catalog.providers.push(updated);
|
|
@@ -134,6 +142,9 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
|
|
|
134
142
|
const models = discovered.models.map(model => ({ id: text(typeof model === 'string' ? model : model.id), capabilities: capabilitiesOfDiscoveredModel(model), source: 'discovered' }));
|
|
135
143
|
result = { command, changed: input.apply === true, total: models.length, mediaCount: models.filter(model => model.capabilities.length).length, models: models.map(model => ({ ...model, id: safe(model.id) })) };
|
|
136
144
|
if (input.apply !== true) return result;
|
|
145
|
+
provider.modelDirectory = { version: 1, binding: modelDirectoryBinding(provider), checkedAt: new Date().toISOString(),
|
|
146
|
+
models: models.map(({ id, capabilities }) => ({ id, capabilities })),
|
|
147
|
+
added: models.filter(model => !entries.some(entry => entry.id === model.id)).map(model => model.id) };
|
|
137
148
|
for (const model of models) {
|
|
138
149
|
const old = entries.find(value => value.id === model.id);
|
|
139
150
|
if (!old) entries.push(model);
|
|
@@ -181,12 +192,14 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
|
|
|
181
192
|
const tested = await probe({ provider, modelId, capability: input.capability, signal });
|
|
182
193
|
if (tested.skipped) return { command, changed: false, ...tested };
|
|
183
194
|
const evidence = { at: new Date().toISOString(), source: 'probe', category: tested.category || (tested.ok ? 'success' : 'unknown'),
|
|
195
|
+
...(tested.providerCode ? { providerCode: safe(tested.providerCode) } : {}),
|
|
184
196
|
...(tested.httpStatus || tested.status ? { httpStatus: tested.httpStatus || tested.status } : {}) };
|
|
185
197
|
entry.verified = { ...(entry.verified || {}), [input.capability]: { ok: tested.ok === true, ...evidence } };
|
|
186
198
|
provider.health ||= { version: 1, revision: 0, observations: [] };
|
|
187
199
|
const observations = provider.health.observations;
|
|
188
200
|
let observation = observations.find(value => value.modelId === modelId && value.capability === input.capability);
|
|
189
201
|
if (!observation) { observation = { modelId, capability: input.capability }; observations.push(observation); }
|
|
202
|
+
recordModelAvailability(provider, modelId, input.capability, { ...tested, ...evidence });
|
|
190
203
|
if (tested.ok === true) { observation.lastSuccess = evidence; clearModelRateLimit(provider, modelId); }
|
|
191
204
|
else {
|
|
192
205
|
observation[isDefinitiveHealthFailure(evidence) ? 'lastFailure' : 'lastTransient'] = evidence;
|
|
@@ -195,11 +208,34 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
|
|
|
195
208
|
result = { command, changed: true, passed: tested.ok === true, ...(tested.taskId ? { taskId: tested.taskId } : {}), ...(tested.code ? { code: tested.code } : {}) };
|
|
196
209
|
} else fail('未知模型命令');
|
|
197
210
|
provider.models = entries;
|
|
211
|
+
if (['models.add', 'models.caps'].includes(command) && input.enableAutomatic !== undefined) {
|
|
212
|
+
if (typeof input.enableAutomatic !== 'boolean') fail('enableAutomatic 必须是布尔值');
|
|
213
|
+
if (input.enableAutomatic) for (const capability of caps(input.capabilities)) {
|
|
214
|
+
catalog.assignments ||= {};
|
|
215
|
+
const previous = functionPolicy(catalog.assignments[capability], capability);
|
|
216
|
+
catalog.assignments[capability] = { mode: 'auto', models: savedAssignmentRefs(modelPool(catalog.providers, { includeHostVision: true }), capability, previous.models),
|
|
217
|
+
...(capability === 'vision' ? { hostVision: previous.hostVision } : {}) };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
198
220
|
} else if (command === 'assignments.set' || command === 'assignments.clear') {
|
|
199
221
|
if (!isKnownCapability(input.capability)) fail('未知能力');
|
|
200
222
|
catalog.assignments ||= {};
|
|
201
223
|
if (command === 'assignments.clear') delete catalog.assignments[input.capability];
|
|
202
|
-
else {
|
|
224
|
+
else if (input.mode !== undefined) {
|
|
225
|
+
const previous = functionPolicy(catalog.assignments[input.capability], input.capability);
|
|
226
|
+
const next = { mode: input.mode, models: savedAssignmentRefs(modelPool(catalog.providers, { includeHostVision: true }), input.capability, previous.models),
|
|
227
|
+
...(input.capability === 'vision' ? { hostVision: input.hostVision ?? previous.hostVision } : {}) };
|
|
228
|
+
functionPolicy(next, input.capability);
|
|
229
|
+
if (input.hostVision && input.capability !== 'vision') fail('hostVision 仅用于视觉');
|
|
230
|
+
if (input.model_refs !== undefined) {
|
|
231
|
+
if (!Array.isArray(input.model_refs) || new Set(input.model_refs).size !== input.model_refs.length) fail('model_refs 必须是不重复的引用数组');
|
|
232
|
+
const pool = modelPool(catalog.providers, { includeHostVision: true });
|
|
233
|
+
for (const ref of input.model_refs) if ((typeof ref !== 'string' || (!parseModelRef(ref) && ref !== HOST_VISION_REF))
|
|
234
|
+
|| !resolvePoolModel(pool, ref, input.capability)) fail('分配模型不存在或没有该能力');
|
|
235
|
+
next.models = input.model_refs;
|
|
236
|
+
}
|
|
237
|
+
catalog.assignments[input.capability] = next;
|
|
238
|
+
} else {
|
|
203
239
|
if (!Array.isArray(input.model_refs) || new Set(input.model_refs).size !== input.model_refs.length) fail('model_refs 必须是不重复的复合引用数组');
|
|
204
240
|
const pool = modelPool(catalog.providers, { includeHostVision: true });
|
|
205
241
|
for (const ref of input.model_refs) if ((!parseModelRef(ref) && ref !== HOST_VISION_REF) || !resolvePoolModel(pool, ref, input.capability)) fail('分配模型不存在或没有该能力');
|
|
@@ -211,8 +247,9 @@ export async function executeConfigCommand(configPath, command, input = {}, { si
|
|
|
211
247
|
if (command === 'providers.remove' || command === 'models.remove' || command === 'models.caps') {
|
|
212
248
|
const pool = modelPool(catalog.providers);
|
|
213
249
|
for (const [capability, assignment] of Object.entries(catalog.assignments || {})) {
|
|
214
|
-
const values = (
|
|
215
|
-
if (!
|
|
250
|
+
const values = functionPolicy(assignment, capability).models.filter(ref => resolvePoolModel(pool, ref, capability) || !resolvePoolModel(initialPool, ref, capability));
|
|
251
|
+
if (assignment && typeof assignment === 'object' && !Array.isArray(assignment) && Object.hasOwn(assignment, 'mode')) catalog.assignments[capability] = { ...assignment, models: values };
|
|
252
|
+
else if (!values.length) delete catalog.assignments[capability];
|
|
216
253
|
else catalog.assignments[capability] = Array.isArray(assignment) ? values : values[0];
|
|
217
254
|
}
|
|
218
255
|
}
|
|
@@ -230,7 +267,37 @@ export function recordCatalogRateLimit(configPath, event) {
|
|
|
230
267
|
return writeConfigChange(configPath, catalog, before, { changed: true });
|
|
231
268
|
}
|
|
232
269
|
|
|
233
|
-
|
|
270
|
+
/** CLI真实提交的可用性证据,复用同一配置锁;不为可重建元数据新增备份。 */
|
|
271
|
+
export function recordCatalogAvailability(configPath, { providerId, modelId, capability, ...event }) {
|
|
272
|
+
const catalog = loadProviderCatalog(configPath);
|
|
273
|
+
const before = fs.readFileSync(configPath);
|
|
274
|
+
const provider = catalog.providers.find(item => item.id === providerId);
|
|
275
|
+
const cap = { image: 'image-gen', video: 'video-gen', music: 'music-gen' }[capability] || capability;
|
|
276
|
+
if (!provider || (event.binding && modelAvailabilityBinding(provider, modelId, cap) !== event.binding)
|
|
277
|
+
|| !recordModelAvailability(provider, modelId, capability, event)) return;
|
|
278
|
+
const directory = currentModelDirectory(provider);
|
|
279
|
+
if (directory && event.ok !== true) directory.invalidatedAt = event.at || new Date().toISOString();
|
|
280
|
+
return writeConfigChange(configPath, catalog, before, { changed: true }, { backup: false });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 新的自动请求按需读取目录;只读CLI命令与手选/停用不走这里。 */
|
|
284
|
+
export async function refreshCatalogDirectories(configPath, catalog, capability, { signal } = {}) {
|
|
285
|
+
if (functionPolicy(catalog.assignments?.[capability], capability).mode !== 'auto') return [];
|
|
286
|
+
return Promise.all(catalog.providers.filter(provider => isConfiguredProvider(provider)
|
|
287
|
+
&& providerModels(provider).some(model => model.capabilities.includes(capability))).map(provider =>
|
|
288
|
+
refreshModelDirectory(provider, { signal, save: directory => {
|
|
289
|
+
const fresh = loadProviderCatalog(configPath);
|
|
290
|
+
const before = fs.readFileSync(configPath);
|
|
291
|
+
const current = fresh.providers.find(item => item.id === provider.id);
|
|
292
|
+
if (!current || modelDirectoryBinding(current) !== directory.binding) return false;
|
|
293
|
+
current.modelDirectory = directory;
|
|
294
|
+
writeConfigChange(configPath, fresh, before, { changed: true }, { backup: false });
|
|
295
|
+
provider.modelDirectory = directory;
|
|
296
|
+
return true;
|
|
297
|
+
} })));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function writeConfigChange(configPath, catalog, before, result, { backup = true } = {}) {
|
|
234
301
|
const lock = configPath + '.lock';
|
|
235
302
|
let fd;
|
|
236
303
|
try { fd = fs.openSync(lock, 'wx', 0o600); }
|
|
@@ -238,10 +305,12 @@ function writeConfigChange(configPath, catalog, before, result) {
|
|
|
238
305
|
try {
|
|
239
306
|
loadProviderCatalog(configPath);
|
|
240
307
|
if (hash(fs.readFileSync(configPath)) !== hash(before)) throw Object.assign(new Error('配置在操作期间发生变化,请重新读取后重试'), { code: 'IRIS_CONFIG_CHANGED' });
|
|
241
|
-
|
|
242
|
-
|
|
308
|
+
if (backup) {
|
|
309
|
+
const backupPath = privateSibling(configPath, 'backup');
|
|
310
|
+
fs.writeFileSync(backupPath, before, { flag: 'wx', mode: 0o600 });
|
|
311
|
+
result.backupPath = backupPath;
|
|
312
|
+
}
|
|
243
313
|
atomicWritePrivate(configPath, JSON.stringify(catalog, null, 2) + '\n');
|
|
244
|
-
result.backupPath = backupPath;
|
|
245
314
|
return result;
|
|
246
315
|
} finally { fs.closeSync(fd); fs.unlinkSync(lock); }
|
|
247
316
|
}
|
package/lib/provider-health.js
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
import { isKnownCapability } from './capability.js';
|
|
3
|
+
import { isModelNotFound, modelAvailabilityBinding, modelUnavailable } from './model-directory.js';
|
|
4
|
+
|
|
5
|
+
const HEALTH_SENSITIVE_FIELDS = new Set([
|
|
6
|
+
'auth', 'type', 'baseUrl', 'mediaBaseUrl', 'apiKey', 'mediaProtocol',
|
|
7
|
+
'imageModel', 'videoModel', 'ttsModel', 'transcribeModel', 'visionModel'
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
/** 明确修改连接后旧健康证据失效;额度停用仍需显式实测解除。 */
|
|
11
|
+
export function resetHealthOnCriticalChange(previous, next, patch) {
|
|
12
|
+
if (![...HEALTH_SENSITIVE_FIELDS].some(key => Object.hasOwn(patch, key) && previous[key] !== next[key])) return;
|
|
13
|
+
const current = previous.health;
|
|
14
|
+
next.health = { version: 1, revision: (Number.isSafeInteger(current?.revision) ? current.revision : 0) + 1,
|
|
15
|
+
observations: [], ...(current?.rateLimits?.length ? { rateLimits: current.rateLimits } : {}) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 仅提交或显式实测更新模型可选性,远端旧任务的观察不解除阻断。 */
|
|
19
|
+
export function recordModelAvailability(provider, modelId, capability, result) {
|
|
20
|
+
const cap = { image: 'image-gen', video: 'video-gen', music: 'music-gen' }[capability] || capability;
|
|
21
|
+
if (!modelId || !isKnownCapability(cap)) return false;
|
|
22
|
+
let observation = provider.health?.observations?.find(item => item.modelId === modelId && item.capability === cap);
|
|
23
|
+
const blocked = isModelNotFound(result);
|
|
24
|
+
if (!blocked && !(result.ok === true && (result.source === 'probe' || result.stage === 'submit')
|
|
25
|
+
&& (observation?.availability?.state === 'blocked' || modelUnavailable(provider, modelId, cap)))) return false;
|
|
26
|
+
provider.health ||= { version: 1, revision: 0, observations: [] };
|
|
27
|
+
provider.health.observations ||= [];
|
|
28
|
+
observation ||= provider.health.observations.find(item => item.modelId === modelId && item.capability === cap);
|
|
29
|
+
if (!observation) { observation = { modelId, capability: cap }; provider.health.observations.push(observation); }
|
|
30
|
+
observation.availability = { state: blocked ? 'blocked' : 'available', at: result.at || new Date().toISOString(),
|
|
31
|
+
binding: result.binding || modelAvailabilityBinding(provider, modelId, cap), source: result.source === 'probe' ? 'probe' : 'task',
|
|
32
|
+
...(blocked ? { httpStatus: 404, reason: 'model_not_found' } : {}) };
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
2
35
|
|
|
3
36
|
export const PROVIDER_HEALTH_SCHEMA_VERSION = 1;
|
|
4
37
|
export const PROVIDER_HEALTH_FRESH_MS = 7 * 24 * 60 * 60 * 1000;
|
package/lib/provider-protocol.js
CHANGED
|
@@ -114,6 +114,36 @@ export function isConfiguredProvider(provider) {
|
|
|
114
114
|
&& (provider.auth === 'none' || (typeof provider.apiKey === 'string' && provider.apiKey.trim())));
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/** 已知需要其他接口的语音系列;自定义模型仍以明确配置和实测为准。 */
|
|
118
|
+
export function dashscopeTtsProtocolIssue(modelId) {
|
|
119
|
+
const model = String(modelId || '');
|
|
120
|
+
if (/^qwen(?:\d+(?:\.\d+)?)?-(?:tts|omni|audio)-.*realtime(?:-|$)/i.test(model)) {
|
|
121
|
+
return '该实时语音模型需要 WebSocket,Iris 尚未接入该调用方式';
|
|
122
|
+
}
|
|
123
|
+
if (/^(?:qwen-audio-|cosyvoice(?:-|$)|sambert-|MiniMax\/speech-)/i.test(model)) {
|
|
124
|
+
return 'Iris 尚未接入该语音系列的合成接口;当前使用 Qwen-TTS 普通 HTTP 接口';
|
|
125
|
+
}
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 复刻/设计模型不能沿用普通模型的默认音色。 */
|
|
130
|
+
export function dashscopeTtsInputIssue(modelId, voice) {
|
|
131
|
+
return dashscopeTtsProtocolIssue(modelId)
|
|
132
|
+
|| (/^qwen(?:\d+)?-tts-(?:vc|vd)(?:-|$)/i.test(String(modelId || '')) && !String(voice || '').trim()
|
|
133
|
+
? '该模型需要先创建复刻或设计音色;请在语音功能填写与该模型匹配的音色 ID 后验证'
|
|
134
|
+
: '');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Protocol support is independent from model capability declarations and health. */
|
|
138
|
+
export function supportsModelCapability(provider, modelId, capability, { checkEndpoint = true } = {}) {
|
|
139
|
+
if (capability === 'vision') return true;
|
|
140
|
+
if (capability === 'music-gen') return provider?.models?.some(model => model?.id === modelId && model.musicProtocol === 'gemini-web-music') === true;
|
|
141
|
+
const protocol = capability === 'image-gen' ? selectImageProtocol(provider, modelId).mediaProtocol : selectMediaProtocol(provider).mediaProtocol;
|
|
142
|
+
if (protocol === 'dashscope') return (capability !== 'tts' || !dashscopeTtsProtocolIssue(modelId))
|
|
143
|
+
&& (!checkEndpoint || isDashScopeBaseUrl(providerMediaBaseUrl(provider)));
|
|
144
|
+
return capability === 'image-gen' && ['openai-images', 'openai-chat-images', 'openai-responses-images'].includes(protocol);
|
|
145
|
+
}
|
|
146
|
+
|
|
117
147
|
/**
|
|
118
148
|
* 生图尺寸的协议差异集中在这里,调用点不再自带厂商假设。
|
|
119
149
|
*
|