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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +147 -5
  3. 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(collectModels(readSettingsCached(profile)), source);
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 = collectModels(readSettingsCached(profile));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-health",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
4
4
  "description": "DSH plugin that lists all configured models with availability/latency testing, by reading $DSH_HOME/settings.yaml.",
5
5
  "icon": "./icon.svg",
6
6
  "license": "MIT",