dsh-model-health 0.2.18 → 0.2.20
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/README.md +1 -0
- package/dist/index.js +147 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,6 +53,7 @@ dsh --profile web --dump-config | grep dsh-model-health # 配置层含本行
|
|
|
53
53
|
**特性一览:**
|
|
54
54
|
|
|
55
55
|
- 支持 `llm-pi-ai`(多协议自定义提供商)与 `llm-deepseek`(官方)两类配置来源
|
|
56
|
+
- 内置模型显示与健康探测:未写入配置文件的宿主内置模型(如 DeepSeek 默认目录 deepseek-flash / deepseek-v4-pro)自动从宿主 `llm` 注册表读取并去重合并;探测经宿主运行时 `ctx.llm.stream` 发起最小请求(max_tokens=1),协议、端点与凭据全部复用宿主适配器,无需额外配置
|
|
56
57
|
- 可用性测试支持 `openai-completions` 与 `deepseek` 协议,其余协议自动标记「跳过」;对 200 响应校验 body 确实是成功应答(部分网关 200 但 body 是错误对象)
|
|
57
58
|
- API Key 通过 DSH credential service(`ctx.credentials.resolve`)解析,密钥不会输出到浏览器
|
|
58
59
|
- 测试结果(状态/延迟/错误)持久化到 localStorage,刷新页面后仍可见
|
package/dist/index.js
CHANGED
|
@@ -307,7 +307,7 @@ async function probeModel(row, apiKey) {
|
|
|
307
307
|
clearTimeout(timer);
|
|
308
308
|
}
|
|
309
309
|
}
|
|
310
|
-
function createTestRouteHandler(resolveApiKey, profile) {
|
|
310
|
+
function createTestRouteHandler(resolveApiKey, profile, probeExtra) {
|
|
311
311
|
return async function handler(req, res) {
|
|
312
312
|
if (!isLocalOrigin(req)) {
|
|
313
313
|
sendJson(res, 403, {
|
|
@@ -331,6 +331,17 @@ function createTestRouteHandler(resolveApiKey, profile) {
|
|
|
331
331
|
try {
|
|
332
332
|
const found = collectModels(readSettingsCached(profile)).find((r) => r.key === key);
|
|
333
333
|
if (!found) {
|
|
334
|
+
if (probeExtra) {
|
|
335
|
+
const probed = await probeExtra(key).catch(() => void 0);
|
|
336
|
+
if (probed) {
|
|
337
|
+
sendJson(res, 200, {
|
|
338
|
+
ok: true,
|
|
339
|
+
key,
|
|
340
|
+
...probed
|
|
341
|
+
});
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
334
345
|
sendJson(res, 404, {
|
|
335
346
|
ok: false,
|
|
336
347
|
error: `未找到 key 为 ${key} 的模型(配置可能已变化,请刷新列表)`
|
|
@@ -374,6 +385,125 @@ function createTestRouteHandler(resolveApiKey, profile) {
|
|
|
374
385
|
};
|
|
375
386
|
}
|
|
376
387
|
//#endregion
|
|
388
|
+
//#region src/host/registry.ts
|
|
389
|
+
/** 从宿主 llm 注册表收集全部已注册模型(内置 + 配置驱动)。 */
|
|
390
|
+
async function collectRegistryModels(llm) {
|
|
391
|
+
const providers = llm.listProviders();
|
|
392
|
+
return (await Promise.all(providers.map(async (p) => {
|
|
393
|
+
let models;
|
|
394
|
+
try {
|
|
395
|
+
models = await llm.listModels(p.id);
|
|
396
|
+
} catch {
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
return Promise.all(models.map(async (m) => {
|
|
400
|
+
let contextWindow = "-";
|
|
401
|
+
let maxTokens = "-";
|
|
402
|
+
if (typeof llm.resolveModelInfo === "function") try {
|
|
403
|
+
const info = await llm.resolveModelInfo(p.id, m.id);
|
|
404
|
+
if (info?.context?.contextWindow) contextWindow = info.context.contextWindow;
|
|
405
|
+
if (info?.defaultMaxTokens) maxTokens = info.defaultMaxTokens;
|
|
406
|
+
} catch {}
|
|
407
|
+
return {
|
|
408
|
+
key: `${p.id}/${m.id}`,
|
|
409
|
+
provider: p.id,
|
|
410
|
+
displayName: p.name || p.id,
|
|
411
|
+
modelId: m.id,
|
|
412
|
+
modelName: m.name || m.id,
|
|
413
|
+
contextWindow,
|
|
414
|
+
maxTokens,
|
|
415
|
+
input: m.inputModalities && m.inputModalities.length > 0 ? m.inputModalities.join("/") : "text",
|
|
416
|
+
api: "registry",
|
|
417
|
+
baseURL: "-",
|
|
418
|
+
apiKeyEnv: ""
|
|
419
|
+
};
|
|
420
|
+
}));
|
|
421
|
+
}))).flat();
|
|
422
|
+
}
|
|
423
|
+
/** 去掉注册表中与文件配置重复的模型(同一 provider 下的同一 modelId)。 */
|
|
424
|
+
function dedupeRegistryRows(registryRows, fileRows) {
|
|
425
|
+
const seen = new Set(fileRows.map((r) => `${r.provider}\u0000${r.modelId}`));
|
|
426
|
+
return registryRows.filter((r) => !seen.has(`${r.provider}\u0000${r.modelId}`));
|
|
427
|
+
}
|
|
428
|
+
const RUNTIME_PROBE_TIMEOUT_MS = 3e4;
|
|
429
|
+
/** 经宿主 llm 运行时对单个模型发起最小探测请求。 */
|
|
430
|
+
async function probeViaRuntime(llm, provider, model) {
|
|
431
|
+
if (typeof llm.stream !== "function") return {
|
|
432
|
+
status: "fail",
|
|
433
|
+
latency: 0,
|
|
434
|
+
error: "宿主 llm 服务不支持 stream 调用"
|
|
435
|
+
};
|
|
436
|
+
const controller = new AbortController();
|
|
437
|
+
const timer = setTimeout(() => controller.abort(), RUNTIME_PROBE_TIMEOUT_MS);
|
|
438
|
+
const start = Date.now();
|
|
439
|
+
try {
|
|
440
|
+
const stream = llm.stream({
|
|
441
|
+
provider,
|
|
442
|
+
model,
|
|
443
|
+
messages: [{
|
|
444
|
+
role: "user",
|
|
445
|
+
content: [{
|
|
446
|
+
type: "text",
|
|
447
|
+
text: "hi"
|
|
448
|
+
}]
|
|
449
|
+
}],
|
|
450
|
+
maxTokens: 1,
|
|
451
|
+
signal: controller.signal
|
|
452
|
+
});
|
|
453
|
+
let firstChunkLatency = 0;
|
|
454
|
+
for await (const chunk of stream) {
|
|
455
|
+
if (chunk.type === "finish") {
|
|
456
|
+
const kind = chunk.reason?.kind;
|
|
457
|
+
if (kind === "error" || kind === "aborted") {
|
|
458
|
+
const failure = chunk.reason?.failure;
|
|
459
|
+
return {
|
|
460
|
+
status: "fail",
|
|
461
|
+
latency: Date.now() - start,
|
|
462
|
+
error: failure?.message ? `${failure.code ?? kind}${failure.status != null ? ` (HTTP ${failure.status})` : ""}:${failure.message}` : `模型调用失败(${kind})`
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
status: "ok",
|
|
467
|
+
latency: firstChunkLatency || Date.now() - start
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
if (!firstChunkLatency && chunk.type !== "usage") firstChunkLatency = Date.now() - start;
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
status: "fail",
|
|
474
|
+
latency: Date.now() - start,
|
|
475
|
+
error: "模型流提前结束(未收到 finish)"
|
|
476
|
+
};
|
|
477
|
+
} catch (e) {
|
|
478
|
+
return {
|
|
479
|
+
status: "fail",
|
|
480
|
+
latency: Date.now() - start,
|
|
481
|
+
error: e?.name === "AbortError" ? `超时(${RUNTIME_PROBE_TIMEOUT_MS / 1e3}s)` : e?.message || String(e)
|
|
482
|
+
};
|
|
483
|
+
} finally {
|
|
484
|
+
clearTimeout(timer);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* 按列表 key(provider/modelId)探测注册表模型;key 不属于注册表时返回
|
|
489
|
+
* undefined(调用方继续走 404)。modelId 可含 '/'(如 pi-ai 的路由模型 id),
|
|
490
|
+
* 因此 provider 段取第一个 '/' 之前。
|
|
491
|
+
*/
|
|
492
|
+
async function probeRegistryKey(llm, key) {
|
|
493
|
+
if (typeof llm.stream !== "function") return void 0;
|
|
494
|
+
const slash = key.indexOf("/");
|
|
495
|
+
if (slash <= 0 || slash === key.length - 1) return void 0;
|
|
496
|
+
const provider = key.slice(0, slash);
|
|
497
|
+
const model = key.slice(slash + 1);
|
|
498
|
+
try {
|
|
499
|
+
if (!llm.listProviders().some((p) => p.id === provider)) return void 0;
|
|
500
|
+
if (!(await llm.listModels(provider)).some((m) => m.id === model)) return void 0;
|
|
501
|
+
} catch {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
return probeViaRuntime(llm, provider, model);
|
|
505
|
+
}
|
|
506
|
+
//#endregion
|
|
377
507
|
//#region src/index.ts
|
|
378
508
|
const name = "dsh-model-health";
|
|
379
509
|
const inject = [
|
|
@@ -383,6 +513,18 @@ const inject = [
|
|
|
383
513
|
];
|
|
384
514
|
function apply(ctx) {
|
|
385
515
|
const profile = ctx.get("profileContext");
|
|
516
|
+
const llm = ctx.get("llm");
|
|
517
|
+
/** 配置文件模型 + 注册表补充模型(内置等文件里没有的条目)。 */
|
|
518
|
+
const collectAllRows = async () => {
|
|
519
|
+
const fileRows = collectModels(readSettingsCached(profile));
|
|
520
|
+
if (!llm) return fileRows;
|
|
521
|
+
try {
|
|
522
|
+
const extra = dedupeRegistryRows(await collectRegistryModels(llm), fileRows);
|
|
523
|
+
return [...fileRows, ...extra];
|
|
524
|
+
} catch {
|
|
525
|
+
return fileRows;
|
|
526
|
+
}
|
|
527
|
+
};
|
|
386
528
|
ctx.tools.register(defineTool({
|
|
387
529
|
name: "list_models",
|
|
388
530
|
description: "列出当前 DSH 配置(settings.yaml 或 profile patch)中已配置的所有模型,以 Markdown 表格展示。用于在对话中查看当前可用模型清单。",
|
|
@@ -396,15 +538,15 @@ function apply(ctx) {
|
|
|
396
538
|
},
|
|
397
539
|
async execute() {
|
|
398
540
|
const source = settingsPaths(profile).join(" + ");
|
|
399
|
-
return renderMarkdownTable(
|
|
541
|
+
return renderMarkdownTable(await collectAllRows(), source);
|
|
400
542
|
}
|
|
401
543
|
}));
|
|
402
544
|
ctx.webServer.register({
|
|
403
545
|
kind: "exact",
|
|
404
546
|
path: "/api/model-health/json",
|
|
405
|
-
handler: (_req, res) => {
|
|
547
|
+
handler: async (_req, res) => {
|
|
406
548
|
try {
|
|
407
|
-
const rows =
|
|
549
|
+
const rows = await collectAllRows();
|
|
408
550
|
sendJson(res, 200, {
|
|
409
551
|
ok: true,
|
|
410
552
|
count: rows.length,
|
|
@@ -429,7 +571,7 @@ function apply(ctx) {
|
|
|
429
571
|
} catch {
|
|
430
572
|
return;
|
|
431
573
|
}
|
|
432
|
-
}, profile)
|
|
574
|
+
}, profile, llm ? (key) => probeRegistryKey(llm, key) : void 0)
|
|
433
575
|
});
|
|
434
576
|
console.log(`[dsh-model-health] ready — tool "list_models" + routes GET /api/model-health/json, POST /api/model-health/test`);
|
|
435
577
|
}
|
package/package.json
CHANGED