@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/actions.js
CHANGED
|
@@ -31,13 +31,21 @@ import { formatOcrResult } from './ocr.js';
|
|
|
31
31
|
import { runOcrRequest } from './ocr-model-routing.js';
|
|
32
32
|
import { ffmpegAvailable, extractFrames, probeVideo } from './media-probe.js';
|
|
33
33
|
import { ensurePrivateDir, writePrivateFile } from './private-storage.js';
|
|
34
|
-
import { selectMediaProtocol, selectImageProtocol, providerForImageModel, supportsImageEdit } from './provider-protocol.js';
|
|
34
|
+
import { selectMediaProtocol, selectImageProtocol, providerForImageModel, supportsImageEdit, supportsModelCapability, dashscopeTtsProtocolIssue, dashscopeTtsInputIssue } from './provider-protocol.js';
|
|
35
35
|
import { musicCandidatesFromCatalog, providerTaskBinding } from './provider-catalog.js';
|
|
36
36
|
import { invokeProviderOperation, providerPollTaskView, prepareProviderInput } from './provider-adapter.js';
|
|
37
37
|
import { createConfiguredProviderAdapter as createProviderAdapter } from './provider-adapters.js';
|
|
38
38
|
import { deriveUserState } from './task-semantics.js';
|
|
39
39
|
import { normalizeGenerationInput } from './generation-input.js';
|
|
40
40
|
import { videoTaskCandidates } from './video-input.js';
|
|
41
|
+
import { normalizeDshProvider, withDshProviderConfig } from './dsh-provider-config.js';
|
|
42
|
+
import { modelDirectorySummary, modelUnavailableReason } from './model-directory.js';
|
|
43
|
+
|
|
44
|
+
const PROVIDER_ACTIONS = new Set(['image', 'image_edit', 'video', 'music', 'tts', 'transcribe', 'look', 'relook',
|
|
45
|
+
'locate', 'ocr', 'media_summarize', 'task_reobserve', 'task_redeliver', 'task_manual_retry',
|
|
46
|
+
'providers_discover', 'providers_sync_directory', 'providers_test_vision', 'providers_test_model']);
|
|
47
|
+
const AUTO_DIRECTORY_ACTIONS = new Set(['image', 'image_edit', 'video', 'music', 'tts', 'transcribe', 'look', 'relook',
|
|
48
|
+
'locate', 'ocr', 'media_summarize']);
|
|
41
49
|
|
|
42
50
|
const MEDIA_TYPES = {
|
|
43
51
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
@@ -46,6 +54,7 @@ const MEDIA_TYPES = {
|
|
|
46
54
|
|
|
47
55
|
function createConfiguredProviderAdapter(provider, options = {}) {
|
|
48
56
|
return createProviderAdapter(provider, { ...options,
|
|
57
|
+
onModelAvailability: event => store.recordAvailability(event),
|
|
49
58
|
onRateLimit: options.allowRateLimited ? undefined : store.recordRateLimit });
|
|
50
59
|
}
|
|
51
60
|
|
|
@@ -92,11 +101,14 @@ function throwIfAborted(signal) {
|
|
|
92
101
|
* 不因后续轮询或结果下载失败重提,避免同一请求重复计费。
|
|
93
102
|
*/
|
|
94
103
|
function selectProviders(capability, label, requestedModelRef) {
|
|
104
|
+
const policy = models.functionPolicy(store.assignments()[capability], capability);
|
|
105
|
+
if (policy.mode === 'disabled') throw new Error('iris: 没有可用' + label + '模型:此功能已停用,请在功能池中选择调用方式');
|
|
95
106
|
let providers = store.pickAllFor(capability);
|
|
96
107
|
const requested = String(requestedModelRef || '').trim();
|
|
97
108
|
if (requested) {
|
|
98
|
-
const pool = models.orderedModels(models.
|
|
109
|
+
const pool = models.orderedModels(models.functionModelPool(store.providers(), capability, store.assignments()[capability]), capability, store.assignments()[capability], { includeRateLimited: true, includeUnavailable: true });
|
|
99
110
|
const selected = models.resolvePoolModel(pool, requested, capability);
|
|
111
|
+
if (selected?.unavailableCapabilities?.includes(capability)) throw new Error('iris: ' + modelUnavailableReason());
|
|
100
112
|
if (selected?.rateLimited) throw Object.assign(new Error(store.modelHealth(selected.providerId, selected.id, capability).retryAt
|
|
101
113
|
? 'iris: 指定模型正在限流冷却,请稍后使用或手动实测'
|
|
102
114
|
: 'iris: 指定模型额度或预算耗尽,请手动实测成功后再使用'), {
|
|
@@ -106,7 +118,7 @@ function selectProviders(capability, label, requestedModelRef) {
|
|
|
106
118
|
providers = selected ? providers.filter((p) => p.id === selected.providerId && p[field] === selected.id) : [];
|
|
107
119
|
if (!providers.length) throw new Error('iris: 指定模型未分配给该功能、不可用或不具备' + label + '能力');
|
|
108
120
|
}
|
|
109
|
-
if (!providers.length) throw new Error('iris: 没有可用' + label + '
|
|
121
|
+
if (!providers.length) throw new Error('iris: 没有可用' + label + '模型:请检查连接、已确认的模型用途、协议和冷却状态;手选模式还需选择模型');
|
|
110
122
|
return providers;
|
|
111
123
|
}
|
|
112
124
|
|
|
@@ -161,13 +173,13 @@ async function observeProviderCapability(route, capability, operation, { recordS
|
|
|
161
173
|
const result = await operation();
|
|
162
174
|
if (recordSuccess && result && ['accepted', 'completed', 'pending', 'succeeded'].includes(result.kind)) {
|
|
163
175
|
recordProviderHealthSafe(route.provider.id, route.model, capability, {
|
|
164
|
-
ok: true,
|
|
176
|
+
ok: true, stage: 'poll',
|
|
165
177
|
category: 'success',
|
|
166
178
|
note: result.kind === 'accepted' ? '任务已受理' : '供应商请求成功'
|
|
167
179
|
});
|
|
168
180
|
} else if (result && result.error) {
|
|
169
181
|
recordProviderHealthSafe(route.provider.id, route.model, capability, {
|
|
170
|
-
ok: false,
|
|
182
|
+
ok: false, stage: 'poll',
|
|
171
183
|
category: result.error.category,
|
|
172
184
|
httpStatus: result.error.httpStatus,
|
|
173
185
|
note: result.error.safeMessage
|
|
@@ -176,7 +188,7 @@ async function observeProviderCapability(route, capability, operation, { recordS
|
|
|
176
188
|
return result;
|
|
177
189
|
} catch (error) {
|
|
178
190
|
recordProviderHealthSafe(route.provider.id, route.model, capability, {
|
|
179
|
-
ok: false,
|
|
191
|
+
ok: false, stage: 'poll',
|
|
180
192
|
category: error && error.category,
|
|
181
193
|
httpStatus: error && (error.httpStatus ?? error.status),
|
|
182
194
|
note: String(error && (error.safeMessage || error.message) || error)
|
|
@@ -200,7 +212,8 @@ function recordVisionHealth(errors) {
|
|
|
200
212
|
|
|
201
213
|
async function askSingleVisionWithHealth(host, request) {
|
|
202
214
|
try {
|
|
203
|
-
const result = await runVisionRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit,
|
|
215
|
+
const result = await runVisionRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit,
|
|
216
|
+
onModelAvailability: store.recordAvailability, ...request });
|
|
204
217
|
recordVisionHealth(result.errors);
|
|
205
218
|
if (result.via === 'selfstack') recordProviderHealthSafe(result.backendId, result.model, cap.CAPABILITIES.VISION, {
|
|
206
219
|
ok: true, category: 'success', note: '视觉请求成功'
|
|
@@ -462,7 +475,14 @@ export function listActions() {
|
|
|
462
475
|
export async function runAction(host, name, args, { signal } = {}) {
|
|
463
476
|
const fn = actions[name];
|
|
464
477
|
if (!fn) throw new Error(`iris: 未知动作 "${name}"`);
|
|
465
|
-
|
|
478
|
+
const adapter = actionHost(host);
|
|
479
|
+
const execute = async () => {
|
|
480
|
+
if (AUTO_DIRECTORY_ACTIONS.has(name) && !args?.model) await store.refreshAutomaticDirectories({
|
|
481
|
+
capability: ({ image: 'image-gen', image_edit: 'image-gen', video: 'video-gen', music: 'music-gen', tts: 'tts', transcribe: 'transcribe' })[name] || 'vision', signal
|
|
482
|
+
});
|
|
483
|
+
return fn(adapter, args || {}, { signal });
|
|
484
|
+
};
|
|
485
|
+
return PROVIDER_ACTIONS.has(name) ? withDshProviderConfig(adapter, execute, { signal }) : execute();
|
|
466
486
|
}
|
|
467
487
|
|
|
468
488
|
/* ============ 动作实现 ============ */
|
|
@@ -585,7 +605,7 @@ register('music', async (host, args, { signal }) => {
|
|
|
585
605
|
...(optionalActionText(args.retry_502) !== undefined ? { retry_502: args.retry_502 } : {}),
|
|
586
606
|
...(optionalActionText(args.retry_delay_ms) !== undefined ? { retry_delay_ms: args.retry_delay_ms } : {})
|
|
587
607
|
});
|
|
588
|
-
const routes = musicCandidatesFromCatalog({ providers: store.
|
|
608
|
+
const routes = musicCandidatesFromCatalog({ providers: store.runtimeProviders(), assignments: store.assignments() }, args.model);
|
|
589
609
|
const submitted = await submitMusicForDsh({
|
|
590
610
|
candidates: routes.map(route => ({ adapter: createConfiguredProviderAdapter(route.provider), model: route.modelRef,
|
|
591
611
|
selectionReason: route.selectionReason, providerBinding: providerTaskBinding(route.provider) })),
|
|
@@ -721,7 +741,8 @@ register('locate', async (host, args, { signal }) => {
|
|
|
721
741
|
const p = String(args.image_path || '').trim();
|
|
722
742
|
if (source === 'image_path' && (!p || !path.isAbsolute(p))) throw new Error('iris: image_path 必须是绝对路径');
|
|
723
743
|
const mt = MEDIA_TYPES[path.extname(p).toLowerCase()] || 'image/png';
|
|
724
|
-
const r = await runLocateRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit,
|
|
744
|
+
const r = await runLocateRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION), onRateLimit: store.recordRateLimit,
|
|
745
|
+
onModelAvailability: store.recordAvailability, model: args.model,
|
|
725
746
|
target: args.target, prepareImage: operationSignal => source === 'artifact_id'
|
|
726
747
|
? visionImageForDsh({ artifactId: args.artifact_id, signal: operationSignal }) : readVisionImageFile(p, mt, operationSignal), signal });
|
|
727
748
|
const { width, height } = r;
|
|
@@ -750,7 +771,7 @@ register('ocr', async (host, args, { signal }) => {
|
|
|
750
771
|
if (!mediaType || mediaType === 'image/gif') throw new Error('iris: 不支持的图片格式(png/jpg/jpeg/webp)');
|
|
751
772
|
}
|
|
752
773
|
const result = await runOcrRequest(host, { providers: store.pickAllFor(cap.CAPABILITIES.VISION),
|
|
753
|
-
onRateLimit: store.recordRateLimit,
|
|
774
|
+
onRateLimit: store.recordRateLimit, onModelAvailability: store.recordAvailability,
|
|
754
775
|
prepareImage: operationSignal => source === 'artifact_id'
|
|
755
776
|
? visionImageForDsh({ artifactId: args.artifact_id, signal: operationSignal }) : readVisionImageFile(p, mediaType, operationSignal),
|
|
756
777
|
chunkHeight: args.chunk_height, overlap: args.overlap, signal });
|
|
@@ -1032,22 +1053,32 @@ register('relook', async (host, args, { signal }) => {
|
|
|
1032
1053
|
/** 列出全部供应商及其模型池(管理 GUI 用;apiKey 只给 hint) */
|
|
1033
1054
|
register('providers_list', async (host, args) => {
|
|
1034
1055
|
const list = store.allProviders();
|
|
1056
|
+
const connections = list.some(provider => provider.dshProvider) && hasHostPort(host, 'providerConnections')
|
|
1057
|
+
? await host.ports.providerConnections.list() : [];
|
|
1035
1058
|
return {
|
|
1036
1059
|
ok: true,
|
|
1060
|
+
setupSuggested: Object.keys(store.assignments()).length === 0 && list.every(provider => models.providerModels(provider).length === 0),
|
|
1037
1061
|
providers: list.map((p) => ({
|
|
1038
1062
|
id: p.id,
|
|
1039
1063
|
name: p.name || p.id,
|
|
1040
1064
|
type: p.type || 'openai',
|
|
1041
|
-
baseUrl: p.
|
|
1065
|
+
baseUrl: p.dshProvider ? (connections.find(item => item.provider === p.dshProvider.provider
|
|
1066
|
+
&& item.settingsNs === p.dshProvider.settingsNs)?.baseUrl || '') : p.baseUrl,
|
|
1067
|
+
...(p.dshProvider ? { dshProvider: p.dshProvider,
|
|
1068
|
+
connection: connections.find(item => item.provider === p.dshProvider.provider && item.settingsNs === p.dshProvider.settingsNs)
|
|
1069
|
+
|| { available: false, reason: 'DSH 原连接不可用,请检查宿主供应商设置' } } : {}),
|
|
1042
1070
|
mediaBaseUrl: p.mediaBaseUrl || '',
|
|
1043
1071
|
enabled: p.enabled !== false,
|
|
1044
1072
|
auth: p.auth === 'none' ? 'none' : 'bearer',
|
|
1045
1073
|
...selectMediaProtocol(p),
|
|
1046
1074
|
visionInput: p.visionInput || null,
|
|
1047
|
-
|
|
1048
|
-
|
|
1075
|
+
directory: modelDirectorySummary(p),
|
|
1076
|
+
apiKeyHint: p.dshProvider ? '由 DSH 管理' : String(p.apiKey || '').length <= 8 ? '****' : String(p.apiKey).slice(0, 3) + '****' + String(p.apiKey).slice(-4),
|
|
1077
|
+
models: models.providerModelsWithDirectory(p).map((m) => ({
|
|
1049
1078
|
id: m.id,
|
|
1050
1079
|
capabilities: m.capabilities,
|
|
1080
|
+
...(m.directoryOnly ? { directoryOnly: true } : {}),
|
|
1081
|
+
...(m.catalogStatus ? { catalogStatus: m.catalogStatus } : {}),
|
|
1051
1082
|
...(m.imageProtocol !== undefined ? { imageProtocol: m.imageProtocol } : {}),
|
|
1052
1083
|
...(m.musicProtocol !== undefined ? { musicProtocol: m.musicProtocol } : {}),
|
|
1053
1084
|
...(m.visionInput !== undefined ? { visionInput: m.visionInput } : {}),
|
|
@@ -1064,6 +1095,12 @@ register('providers_list', async (host, args) => {
|
|
|
1064
1095
|
};
|
|
1065
1096
|
});
|
|
1066
1097
|
|
|
1098
|
+
register('providers_host_connections', async host => {
|
|
1099
|
+
if (!hasHostPort(host, 'providerConnections')) return { ok: true, connections: [],
|
|
1100
|
+
note: '当前 DSH 未提供连接复用接口,请手动添加 Iris 供应商' };
|
|
1101
|
+
return { ok: true, connections: await host.ports.providerConnections.list() };
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1067
1104
|
/** 新增/更新供应商 */
|
|
1068
1105
|
register('providers_upsert', async (host, args) => {
|
|
1069
1106
|
const id = args.id || undefined;
|
|
@@ -1073,6 +1110,16 @@ register('providers_upsert', async (host, args) => {
|
|
|
1073
1110
|
for (const key of allowed) {
|
|
1074
1111
|
if (Object.prototype.hasOwnProperty.call(args, key)) patch[key] = args[key];
|
|
1075
1112
|
}
|
|
1113
|
+
if (Object.hasOwn(args, 'dshProvider')) {
|
|
1114
|
+
patch.dshProvider = normalizeDshProvider(args.dshProvider);
|
|
1115
|
+
const source = requireHostPort(host, 'providerConnections', '复用 DSH 供应商连接');
|
|
1116
|
+
const connection = (await source.list()).find(item => item.provider === patch.dshProvider.provider
|
|
1117
|
+
&& item.settingsNs === patch.dshProvider.settingsNs);
|
|
1118
|
+
if (!connection?.available) throw new Error(connection?.reason || 'DSH 连接不可用');
|
|
1119
|
+
if (!['dashscope', 'openai-images', 'openai-chat-images', 'openai-responses-images'].includes(patch.mediaProtocol)) {
|
|
1120
|
+
throw new Error('复用 DSH 连接时请明确选择媒体协议');
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1076
1123
|
if (!id && !Object.prototype.hasOwnProperty.call(patch, 'enabled')) patch.enabled = true;
|
|
1077
1124
|
const saved = store.upsert(patch);
|
|
1078
1125
|
return { ok: true, providerId: saved.id };
|
|
@@ -1098,21 +1145,27 @@ register('providers_set_models', async (host, args) => {
|
|
|
1098
1145
|
register('providers_discover', async (host, args, { signal }) => {
|
|
1099
1146
|
const p = store.providerById(String(args.id || ''));
|
|
1100
1147
|
if (!p) throw new Error('iris: 供应商不存在或未启用');
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
const discovered = await invokeProviderOperation(providerAdapter, 'discover', { signal });
|
|
1105
|
-
discoveredModels = discovered.models;
|
|
1106
|
-
} catch (err) {
|
|
1107
|
-
throw new Error('iris: 模型发现失败(保留现有池):' + String(err.message || err));
|
|
1108
|
-
}
|
|
1109
|
-
const entries = discoveredModels.map((model) => ({ id: model.id, capabilities: models.capabilitiesOfDiscoveredModel(model) }));
|
|
1148
|
+
const [updated] = await store.refreshProviderDirectories({ providerId: p.id, force: true, signal });
|
|
1149
|
+
if (updated.error) throw new Error('iris: 模型发现失败(保留现有池):' + updated.error);
|
|
1150
|
+
const entries = updated.directory.models;
|
|
1110
1151
|
const mediaCount = entries.filter((model) => model.capabilities.length).length;
|
|
1111
1152
|
store.mergeDiscoveredModels(p.id, entries);
|
|
1112
|
-
return { ok: true, total:
|
|
1153
|
+
return { ok: true, total: entries.length, media: mediaCount,
|
|
1113
1154
|
text: `[iris] ${p.name}:发现 ${entries.length} 个模型,${mediaCount} 个有能力声明;未标注模型请在模型池中设置能力` };
|
|
1114
1155
|
});
|
|
1115
1156
|
|
|
1157
|
+
/** 只更新目录快照;新增项在管理视图待确认,不改模型用途与功能名单。 */
|
|
1158
|
+
register('providers_sync_directory', async (host, args, { signal }) => {
|
|
1159
|
+
const id = String(args.id || '').trim();
|
|
1160
|
+
if (!store.providerById(id)) throw new Error('iris: 供应商不存在或连接未就绪');
|
|
1161
|
+
const [result] = await store.refreshProviderDirectories({ providerId: id, force: args.force === true, signal });
|
|
1162
|
+
if (result.error) return { ok: true, synced: false, error: result.error,
|
|
1163
|
+
text: '目录同步失败,已保留上次完整目录:' + result.error };
|
|
1164
|
+
return { ok: true, synced: true, refreshed: result.refreshed,
|
|
1165
|
+
text: result.refreshed ? '模型目录已同步;新模型仍需确认用途' : '目录仍在有效期内',
|
|
1166
|
+
directory: modelDirectorySummary(store.providerById(id)) };
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1116
1169
|
/** 能力实测(红色测试图):对某供应商的 vision 模型跑 testVisionCapability */
|
|
1117
1170
|
register('providers_test_vision', async (host, args, { signal }) => {
|
|
1118
1171
|
const p = store.providerById(String(args.id || ''));
|
|
@@ -1121,7 +1174,7 @@ register('providers_test_vision', async (host, args, { signal }) => {
|
|
|
1121
1174
|
if (!vision) return { ok: true, tested: false, text: '该供应商未配置 vision 模型,跳过' };
|
|
1122
1175
|
const model = vision.id;
|
|
1123
1176
|
if (args.confirm_paid !== true) throw new Error('iris: 视觉实测会发起真实供应商请求,请先明确确认');
|
|
1124
|
-
const r = await probeVisionModel(p, model, visionImageFromDataUrl(RED_TEST_IMAGE), { timeoutMs: 15000, signal });
|
|
1177
|
+
const r = await probeVisionModel(p, model, visionImageFromDataUrl(RED_TEST_IMAGE), { timeoutMs: 15000, signal, onModelAvailability: store.recordAvailability });
|
|
1125
1178
|
store.setModelVerified(p.id, model, cap.CAPABILITIES.VISION, {
|
|
1126
1179
|
ok: r.ok,
|
|
1127
1180
|
note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色'),
|
|
@@ -1146,7 +1199,7 @@ async function probeModel(host, p, modelId, capability, signal) {
|
|
|
1146
1199
|
return { skipped: true, ok: false, note: 'Music 不发起小样本探测;请通过音乐功能提交实际创作要求' };
|
|
1147
1200
|
}
|
|
1148
1201
|
if (capability === cap.CAPABILITIES.VISION) {
|
|
1149
|
-
const r = await probeVisionModel(p, modelId, visionImageFromDataUrl(RED_TEST_IMAGE), { timeoutMs: 15000, signal });
|
|
1202
|
+
const r = await probeVisionModel(p, modelId, visionImageFromDataUrl(RED_TEST_IMAGE), { timeoutMs: 15000, signal, onModelAvailability: store.recordAvailability });
|
|
1150
1203
|
return {
|
|
1151
1204
|
ok: r.ok,
|
|
1152
1205
|
note: r.ok ? '认出红色测试图' : (r.error || '未识别出红色'),
|
|
@@ -1158,6 +1211,8 @@ async function probeModel(host, p, modelId, capability, signal) {
|
|
|
1158
1211
|
return { skipped: true, ok: false, note: '需真实音频;可用 CLI models test --capability transcribe --input 提供素材进行实测恢复' };
|
|
1159
1212
|
}
|
|
1160
1213
|
if (capability === cap.CAPABILITIES.TTS) {
|
|
1214
|
+
const issue = selectMediaProtocol(p).mediaProtocol === 'dashscope' && dashscopeTtsInputIssue(modelId);
|
|
1215
|
+
if (issue) return { skipped: true, ok: false, note: issue };
|
|
1161
1216
|
const providerAdapter = createConfiguredProviderAdapter(p, { allowRateLimited: true });
|
|
1162
1217
|
const submitted = await invokeProviderOperation(providerAdapter, 'submit', {
|
|
1163
1218
|
capability: 'tts', model: modelId, input: { text: '你好' }, signal
|
|
@@ -1231,6 +1286,14 @@ register('providers_add_model', async (host, args) => {
|
|
|
1231
1286
|
return { ok: true, text: `[iris] 已添加模型 ${modelId}(能力:${(caps || []).join(',') || '未标注'})` };
|
|
1232
1287
|
});
|
|
1233
1288
|
|
|
1289
|
+
register('providers_confirm_model', async (_host, args) => {
|
|
1290
|
+
const entry = store.confirmModelUsage(args.id, args.model_id, args.capabilities, {
|
|
1291
|
+
enableAutomatic: args.enableAutomatic, musicProtocol: args.musicProtocol
|
|
1292
|
+
});
|
|
1293
|
+
return { ok: true, model_id: entry.id, text: args.enableAutomatic
|
|
1294
|
+
? '已确认模型用途,并启用所选功能的自动池' : '已确认模型用途;已有调用方式保持' };
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1234
1297
|
/** 从池移除模型 */
|
|
1235
1298
|
register('providers_remove_model', async (host, args) => {
|
|
1236
1299
|
const ok = store.removeProviderModel(String(args.id || ''), String(args.model_id || ''));
|
|
@@ -1303,22 +1366,62 @@ register('providers_test_model', async (host, args, { signal }) => {
|
|
|
1303
1366
|
register('assignments_get', async (host, args) => {
|
|
1304
1367
|
const providers = store.allProviders();
|
|
1305
1368
|
const configured = new Set(store.providers().map(provider => provider.id));
|
|
1369
|
+
const connections = providers.some(provider => provider.dshProvider) && hasHostPort(host, 'providerConnections')
|
|
1370
|
+
? await host.ports.providerConnections.list() : [];
|
|
1371
|
+
for (const provider of providers) {
|
|
1372
|
+
if (provider.enabled !== false && provider.dshProvider && connections.some(item => item.available
|
|
1373
|
+
&& item.provider === provider.dshProvider.provider && item.settingsNs === provider.dshProvider.settingsNs)) {
|
|
1374
|
+
configured.add(provider.id);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
const previewProviders = providers.map(provider => provider.dshProvider ? { ...provider,
|
|
1378
|
+
baseUrl: connections.find(item => item.provider === provider.dshProvider.provider
|
|
1379
|
+
&& item.settingsNs === provider.dshProvider.settingsNs)?.baseUrl } : provider);
|
|
1306
1380
|
const pool = models.modelPool(providers, { includeHostVision: true });
|
|
1307
1381
|
const byCap = {};
|
|
1308
1382
|
for (const m of pool) {
|
|
1309
1383
|
for (const c of m.capabilities) {
|
|
1310
1384
|
if (!byCap[c]) byCap[c] = [];
|
|
1311
|
-
const provider =
|
|
1385
|
+
const provider = previewProviders.find(provider => provider.id === m.providerId);
|
|
1386
|
+
const hostResource = m.ref === models.HOST_VISION_REF;
|
|
1387
|
+
const protocolSupported = hostResource || supportsModelCapability(provider, m.id, c);
|
|
1388
|
+
const modelUnavailable = m.unavailableCapabilities?.includes(c) === true;
|
|
1389
|
+
const connected = hostResource ? hasHostPort(host, 'textModel') : configured.has(m.providerId);
|
|
1312
1390
|
byCap[c].push({ ref: m.ref, id: m.id, providerId: m.providerId,
|
|
1313
1391
|
label: m.ref === models.HOST_VISION_REF ? 'DSH 当前默认模型' : m.id + ' · ' + m.providerId,
|
|
1314
|
-
unavailable:
|
|
1392
|
+
unavailable: !connected || !protocolSupported || m.rateLimited === true || modelUnavailable,
|
|
1393
|
+
modelUnavailable,
|
|
1394
|
+
pendingConfirmation: m.source === 'discovered', protocolSupported,
|
|
1395
|
+
reason: !connected ? '连接未就绪,请检查供应商或宿主服务' : !protocolSupported
|
|
1396
|
+
? (c === cap.CAPABILITIES.TTS && selectMediaProtocol(provider).mediaProtocol === 'dashscope'
|
|
1397
|
+
&& dashscopeTtsProtocolIssue(m.id)) || '当前接口协议不支持此功能'
|
|
1398
|
+
: modelUnavailable ? modelUnavailableReason() : m.rateLimited ? '限流冷却或额度不可用,请稍后或检查账号' : m.source === 'discovered' ? '已发现,待确认模型用途' : '',
|
|
1315
1399
|
...(c === cap.CAPABILITIES.IMAGE && provider ? { imageRouting: imageRoutingForModel(provider, m.id) } : {}) });
|
|
1316
1400
|
}
|
|
1317
1401
|
}
|
|
1318
1402
|
// order:归一化的有序分配列表(GUI 直接渲染 failover 顺序,无需处理单字符串旧格式)
|
|
1319
1403
|
const order = {};
|
|
1320
1404
|
for (const c of Object.values(cap.CAPABILITIES)) order[c] = store.assignmentOrder(c);
|
|
1321
|
-
|
|
1405
|
+
const policies = {}, readiness = {};
|
|
1406
|
+
for (const capability of Object.values(cap.CAPABILITIES)) {
|
|
1407
|
+
const assignment = store.assignments()[capability];
|
|
1408
|
+
const policy = models.functionPolicy(assignment, capability);
|
|
1409
|
+
policies[capability] = policy;
|
|
1410
|
+
if (policy.mode === 'auto') order[capability] = models.orderedModels(
|
|
1411
|
+
models.functionModelPool(previewProviders, capability, assignment, { includeHostVision: true }),
|
|
1412
|
+
capability, assignment, { includeRateLimited: true, includeUnavailable: true }).map(model => model.ref);
|
|
1413
|
+
const options = byCap[capability] || [];
|
|
1414
|
+
const candidates = options.filter(model => order[capability].includes(model.ref) && !model.unavailable);
|
|
1415
|
+
let note = '';
|
|
1416
|
+
if (policy.mode === 'disabled') note = '此功能已停用,可选择自动池或手选模型';
|
|
1417
|
+
else if (policy.mode === 'manual' && !order[capability].length) note = '尚未选择模型,可添加所选项或改用自动池';
|
|
1418
|
+
else if (!options.length) note = '请先添加模型,并确认它可用于此功能';
|
|
1419
|
+
else if (!candidates.length) note = options.some(model => model.pendingConfirmation)
|
|
1420
|
+
? '发现的模型尚待确认用途,请在供应商中确认功能'
|
|
1421
|
+
: options.find(model => model.reason)?.reason || '当前没有匹配的候选,请检查功能范围和模型';
|
|
1422
|
+
readiness[capability] = { mode: policy.mode, candidateCount: candidates.length, note };
|
|
1423
|
+
}
|
|
1424
|
+
return { ok: true, assignments: store.assignments(), order, poolByCapability: byCap, policies, readiness };
|
|
1322
1425
|
});
|
|
1323
1426
|
|
|
1324
1427
|
/**
|
|
@@ -1329,6 +1432,10 @@ register('assignments_get', async (host, args) => {
|
|
|
1329
1432
|
register('assignments_set', async (host, args) => {
|
|
1330
1433
|
const capability = String(args.capability || '').trim();
|
|
1331
1434
|
if (!capability) throw new Error('iris: capability 不能为空');
|
|
1435
|
+
if (Object.hasOwn(args, 'mode')) {
|
|
1436
|
+
const policy = store.setFunctionPolicy(capability, args.mode, { hostVision: args.hostVision });
|
|
1437
|
+
return { ok: true, capability, mode: policy.mode, model_refs: store.assignmentOrder(capability) };
|
|
1438
|
+
}
|
|
1332
1439
|
const listArg = Array.isArray(args.model_refs) ? args.model_refs : args.model_ids;
|
|
1333
1440
|
if (Array.isArray(listArg)) {
|
|
1334
1441
|
const refs = listArg.map((x) => String(x || '').trim()).filter(Boolean);
|
|
@@ -1465,7 +1572,7 @@ register('media_summarize', async (host, args, { signal }) => {
|
|
|
1465
1572
|
if (!ffmpegAvailable()) throw new Error('iris: ffmpeg/ffprobe 不可用,无法分析视频(可选安装 ffmpeg 后启用)');
|
|
1466
1573
|
}
|
|
1467
1574
|
const { answer, via, model, sheet, meta, frames } = await runSummaryRequest(host, {
|
|
1468
|
-
onRateLimit: store.recordRateLimit,
|
|
1575
|
+
onRateLimit: store.recordRateLimit, onModelAvailability: store.recordAvailability,
|
|
1469
1576
|
providers: store.pickAllFor(cap.CAPABILITIES.VISION), model: args.model, question: args.question, signal,
|
|
1470
1577
|
async prepareMedia(operationSignal) {
|
|
1471
1578
|
if (source === 'core-artifacts') {
|
package/lib/adapters.js
CHANGED
|
@@ -367,7 +367,15 @@ export async function synthesizeTts({ key, baseUrl, model = 'qwen-tts-latest', t
|
|
|
367
367
|
body: JSON.stringify({ model, input: { text, voice } })
|
|
368
368
|
});
|
|
369
369
|
const body = await readJson(res);
|
|
370
|
-
if (!res.ok)
|
|
370
|
+
if (!res.ok) {
|
|
371
|
+
const error = providerError(res.status, body, '合成失败', res.headers);
|
|
372
|
+
if (res.status === 404 && (/NotFound\.Model|model_not_found/i.test(error.providerCode)
|
|
373
|
+
|| /model (?:not exist|can not be found|.*does not exist)/i.test(String(error.rootCause)))) {
|
|
374
|
+
error.category = 'invalid_parameter';
|
|
375
|
+
error.message = `当前语音接口找不到模型 ${model}(${new URL(apiBase).hostname});请核对模型 ID、所属地域及调用协议。上游:${error.rootCause}`;
|
|
376
|
+
}
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
371
379
|
const audio = body && body.output && body.output.audio;
|
|
372
380
|
const url = audio && (audio.url || audio.audio_url);
|
|
373
381
|
const b64 = audio && typeof audio.data === 'string' ? audio.data : null;
|
|
@@ -510,6 +518,7 @@ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal })
|
|
|
510
518
|
const found = new Map();
|
|
511
519
|
const maxPages = dashscope ? 50 : 1;
|
|
512
520
|
let loaded = 0;
|
|
521
|
+
let expectedTotal = null;
|
|
513
522
|
for (let page = 1; page <= maxPages; page++) {
|
|
514
523
|
const url = new URL(endpoint);
|
|
515
524
|
if (dashscope) {
|
|
@@ -519,14 +528,24 @@ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal })
|
|
|
519
528
|
const res = await fetch(url, { headers: authHeaders(key), signal: timeoutSignal(timeoutMs, signal) });
|
|
520
529
|
const body = await readJson(res);
|
|
521
530
|
if (!res.ok) throw providerError(res.status, body, '模型发现失败', res.headers);
|
|
531
|
+
if (!body || body.success === false) throw new ProviderContractError('模型目录响应无效,已保留上次完整目录', {
|
|
532
|
+
stage: 'validate', category: 'protocol', acceptance: 'not_accepted'
|
|
533
|
+
});
|
|
522
534
|
const output = body && body.output;
|
|
523
535
|
const items = (output && Array.isArray(output.models) && output.models)
|
|
524
536
|
|| (body && Array.isArray(body.data) && body.data)
|
|
525
|
-
|| (Array.isArray(body && body.models) ? body.models :
|
|
537
|
+
|| (Array.isArray(body && body.models) ? body.models : null);
|
|
538
|
+
if (!items) throw new ProviderContractError('模型目录缺少模型数组,已保留上次完整目录', {
|
|
539
|
+
stage: 'validate', category: 'protocol', acceptance: 'not_accepted'
|
|
540
|
+
});
|
|
526
541
|
loaded += items.length;
|
|
527
542
|
for (const item of items) {
|
|
528
|
-
const
|
|
529
|
-
if (
|
|
543
|
+
const rawId = typeof item === 'string' ? item : item?.id || item?.model;
|
|
544
|
+
if (typeof rawId !== 'string' || !rawId.trim()) throw new ProviderContractError('模型目录包含无效模型 ID,已保留上次完整目录', {
|
|
545
|
+
stage: 'validate', category: 'protocol', acceptance: 'not_accepted'
|
|
546
|
+
});
|
|
547
|
+
const id = rawId.trim();
|
|
548
|
+
if (found.has(id)) continue;
|
|
530
549
|
const capabilities = Array.isArray(item?.capabilities)
|
|
531
550
|
? item.capabilities.filter((value) => typeof value === 'string' && value)
|
|
532
551
|
: [];
|
|
@@ -538,17 +557,25 @@ export async function listModels({ key, baseUrl, timeoutMs = T_SUBMIT, signal })
|
|
|
538
557
|
if (!Array.isArray(item?.capabilities)) {
|
|
539
558
|
if (responseModalities.includes('image')) capabilities.push('IG');
|
|
540
559
|
if (responseModalities.includes('video')) capabilities.push('VG');
|
|
541
|
-
|
|
542
|
-
if (requestModalities.
|
|
560
|
+
// 音频理解不证明支持文件转写;混合视频/图像/文本输出也不证明独立TTS。
|
|
561
|
+
if (requestModalities.length === 1 && requestModalities[0] === 'text'
|
|
562
|
+
&& responseModalities.length === 1 && responseModalities[0] === 'audio') capabilities.push('TTS');
|
|
543
563
|
if (requestModalities.includes('image') && responseModalities.includes('text')) capabilities.push('VU');
|
|
544
564
|
}
|
|
545
565
|
const normalizedCapabilities = [...new Set(capabilities)];
|
|
546
566
|
found.set(id, normalizedCapabilities.length ? { id, capabilities: normalizedCapabilities } : id);
|
|
547
567
|
}
|
|
548
568
|
if (!dashscope) break;
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
569
|
+
const total = output?.total === undefined ? expectedTotal : Number(output.total);
|
|
570
|
+
if (total !== null && (!Number.isSafeInteger(total) || total < 0
|
|
571
|
+
|| (expectedTotal !== null && total !== expectedTotal) || loaded > total || (!items.length && loaded < total)
|
|
572
|
+
|| (loaded === total && found.size !== loaded))) {
|
|
573
|
+
throw new ProviderContractError('模型目录分页不完整或总数变化,已保留上次完整目录', {
|
|
574
|
+
stage: 'validate', category: 'protocol', acceptance: 'not_accepted'
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
if (total !== null) expectedTotal = total;
|
|
578
|
+
if ((total !== null && loaded === total) || (total === null && items.length < 100)) break;
|
|
552
579
|
if (page === maxPages) throw new Error('iris: 模型目录超过安全分页上限,已保留原模型池');
|
|
553
580
|
}
|
|
554
581
|
return [...found.values()];
|
package/lib/api.js
CHANGED
|
@@ -66,7 +66,8 @@ function providerPublic(p) {
|
|
|
66
66
|
mediaBaseUrl: p.mediaBaseUrl || '',
|
|
67
67
|
enabled: p.enabled !== false,
|
|
68
68
|
...selectMediaProtocol(p),
|
|
69
|
-
apiKeyHint: keyHint(p.apiKey),
|
|
69
|
+
apiKeyHint: p.dshProvider ? '由 DSH 管理' : keyHint(p.apiKey),
|
|
70
|
+
...(p.dshProvider ? { dshProvider: p.dshProvider } : {}),
|
|
70
71
|
capabilities: capabilitiesOf(p),
|
|
71
72
|
// 迁移/推断结果是否与原始声明不同(UI 提示旧配置已推断)
|
|
72
73
|
capabilityInferred: !Array.isArray(p.capabilities) || p.capabilities.length === 0,
|