dsh-model-health 0.2.19 → 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 -1
  2. package/dist/index.js +90 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -53,7 +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` 注册表读取并去重合并;此类模型协议与凭据由宿主适配器管理,暂不支持在线探测(标记「跳过」)
56
+ - 内置模型显示与健康探测:未写入配置文件的宿主内置模型(如 DeepSeek 默认目录 deepseek-flash / deepseek-v4-pro)自动从宿主 `llm` 注册表读取并去重合并;探测经宿主运行时 `ctx.llm.stream` 发起最小请求(max_tokens=1),协议、端点与凭据全部复用宿主适配器,无需额外配置
57
57
  - 可用性测试支持 `openai-completions` 与 `deepseek` 协议,其余协议自动标记「跳过」;对 200 响应校验 body 确实是成功应答(部分网关 200 但 body 是错误对象)
58
58
  - API Key 通过 DSH credential service(`ctx.credentials.resolve`)解析,密钥不会输出到浏览器
59
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, findExtraRow) {
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,14 +331,16 @@ function createTestRouteHandler(resolveApiKey, profile, findExtraRow) {
331
331
  try {
332
332
  const found = collectModels(readSettingsCached(profile)).find((r) => r.key === key);
333
333
  if (!found) {
334
- if (findExtraRow ? await findExtraRow(key).catch(() => void 0) : void 0) {
335
- sendJson(res, 200, {
336
- ok: true,
337
- key,
338
- status: "skip",
339
- error: "宿主内置模型:协议与凭据由宿主适配器管理,暂不支持在线探测"
340
- });
341
- return;
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
+ }
342
344
  }
343
345
  sendJson(res, 404, {
344
346
  ok: false,
@@ -423,6 +425,84 @@ function dedupeRegistryRows(registryRows, fileRows) {
423
425
  const seen = new Set(fileRows.map((r) => `${r.provider}\u0000${r.modelId}`));
424
426
  return registryRows.filter((r) => !seen.has(`${r.provider}\u0000${r.modelId}`));
425
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
+ }
426
506
  //#endregion
427
507
  //#region src/index.ts
428
508
  const name = "dsh-model-health";
@@ -491,9 +571,7 @@ function apply(ctx) {
491
571
  } catch {
492
572
  return;
493
573
  }
494
- }, profile, llm ? async (key) => {
495
- return (await collectRegistryModels(llm)).find((r) => r.key === key);
496
- } : void 0)
574
+ }, profile, llm ? (key) => probeRegistryKey(llm, key) : void 0)
497
575
  });
498
576
  console.log(`[dsh-model-health] ready — tool "list_models" + routes GET /api/model-health/json, POST /api/model-health/test`);
499
577
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-health",
3
- "version": "0.2.19",
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",