dsh-model-health 0.2.7 → 0.2.9

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 CHANGED
@@ -53,11 +53,11 @@ dsh --profile web --dump-config | grep dsh-model-health # 配置层含本行
53
53
  **特性一览:**
54
54
 
55
55
  - 支持 `llm-pi-ai`(多协议自定义提供商)与 `llm-deepseek`(官方)两类配置来源
56
- - 可用性测试支持 `openai-completions` 与 `deepseek` 协议,其余协议自动标记「跳过」
56
+ - 可用性测试支持 `openai-completions` 与 `deepseek` 协议,其余协议自动标记「跳过」;对 200 响应校验 body 确实是成功应答(部分网关 200 但 body 是错误对象)
57
57
  - API Key 通过 DSH credential service(`ctx.credentials.resolve`)解析,密钥不会输出到浏览器
58
58
  - 测试结果(状态/延迟/错误)持久化到 localStorage,刷新页面后仍可见
59
59
  - 状态列的 可用 / 不可用 / 跳过 徽章随时可点击,对单个模型重新测试,原地刷新状态与延迟
60
- - 设置导航使用脉搏线(Activity)图标——宿主图标表没有「健康」类目,参考 dsh-better-sidebar 的方案,JS 精准打 marker,CSS 用 `::before` + `mask` 把齿轮 svg 原地替换;颜色随主题走
60
+ - 设置导航使用脉搏线(Activity)图标——宿主图标表没有「健康」类目,参考 dsh-better-sidebar 的方案,JS 精准打 marker(rAF 合帧防抖),CSS 用 `::before` + `mask` 把齿轮 svg 原地替换;颜色随主题走
61
61
 
62
62
  ## 实现方式 / How it works
63
63
 
@@ -69,6 +69,7 @@ dsh --profile web --dump-config | grep dsh-model-health # 配置层含本行
69
69
  │ - ctx.webServer.register: │
70
70
  │ GET /api/model-health/json 读取并解析 settings.yaml → JSON │
71
71
  │ POST /api/model-health/test 对单模型发最小请求测可用性/延迟 │
72
+ │ (Origin 限本机;200 响应校验 body 为成功应答) │
72
73
  │ - 解析 DSH credential service 获取 API Key │
73
74
  └──────────────────────────────────────────────────────────────────┘
74
75
  │ fetch
package/dist/client.js CHANGED
@@ -394,18 +394,39 @@
394
394
  //#endregion
395
395
  //#region src/client/storage.ts
396
396
  const STORAGE_KEY = "dsh-model-health:test-results";
397
+ const VALID_STATUSES = /* @__PURE__ */ new Set([
398
+ "idle",
399
+ "testing",
400
+ "ok",
401
+ "fail",
402
+ "skip"
403
+ ]);
404
+ /**
405
+ * 清洗任意来源(localStorage / 旧版本数据)的结果对象:
406
+ * - 残留的 testing(测试中刷新页面)归一为 idle
407
+ * - 旧版按数组下标存的纯数字 key 直接丢弃
408
+ * 纯函数,可单测。
409
+ */
410
+ function sanitizeStoredResults(parsed) {
411
+ const cleaned = {};
412
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return cleaned;
413
+ for (const [k, v] of Object.entries(parsed)) {
414
+ if (/^\d+$/.test(k)) continue;
415
+ if (!v || typeof v !== "object") continue;
416
+ const raw = v;
417
+ const status = raw.status;
418
+ if (!VALID_STATUSES.has(status)) continue;
419
+ const result = { status: status === "testing" ? "idle" : status };
420
+ if (typeof raw.latency === "number" && raw.latency >= 0) result.latency = raw.latency;
421
+ if (typeof raw.error === "string") result.error = raw.error;
422
+ cleaned[k] = result;
423
+ }
424
+ return cleaned;
425
+ }
397
426
  function loadStoredResults() {
398
427
  try {
399
428
  const raw = localStorage.getItem(STORAGE_KEY);
400
- const parsed = raw ? JSON.parse(raw) : {};
401
- const cleaned = {};
402
- for (const k in parsed) {
403
- if (/^\d+$/.test(k)) continue;
404
- const v = parsed[k];
405
- if (!v || typeof v !== "object") continue;
406
- cleaned[k] = v.status === "testing" ? { status: "idle" } : v;
407
- }
408
- return cleaned;
429
+ return raw ? sanitizeStoredResults(JSON.parse(raw)) : {};
409
430
  } catch {
410
431
  return {};
411
432
  }
@@ -441,12 +462,15 @@
441
462
  });
442
463
  }, []);
443
464
  const testOne = useCallback(async (key) => {
465
+ setResults((s) => {
466
+ if (s[key]?.status === "testing") return s;
467
+ return {
468
+ ...s,
469
+ [key]: { status: "testing" }
470
+ };
471
+ });
444
472
  const seq = (seqRef.current[key] || 0) + 1;
445
473
  seqRef.current[key] = seq;
446
- setResults((s) => ({
447
- ...s,
448
- [key]: { status: "testing" }
449
- }));
450
474
  const result = await requestModelTest(key);
451
475
  writeResult(key, seq, result);
452
476
  }, [writeResult]);
@@ -465,7 +489,7 @@
465
489
  });
466
490
  let done = 0;
467
491
  const queue = models.map((m) => m.key);
468
- async function worker() {
492
+ const worker = async () => {
469
493
  while (queue.length > 0) {
470
494
  const key = queue.shift();
471
495
  if (key === void 0) break;
@@ -477,9 +501,20 @@
477
501
  });
478
502
  if (done >= total) setTesting(false);
479
503
  }
480
- }
504
+ };
481
505
  await Promise.all(Array.from({ length: Math.min(CONCURRENCY, total) }, () => worker()));
482
- }, [testOne])
506
+ }, [testOne]),
507
+ prune: useCallback((validKeys) => {
508
+ setResults((s) => {
509
+ const next = {};
510
+ let changed = false;
511
+ for (const k in s) if (validKeys.has(k)) next[k] = s[k];
512
+ else changed = true;
513
+ if (!changed) return s;
514
+ saveStoredResults(next);
515
+ return next;
516
+ });
517
+ }, [])
483
518
  };
484
519
  }
485
520
 
@@ -552,7 +587,7 @@
552
587
  //#region src/client/components/ModelListSection.tsx
553
588
  function ModelListSection({ t, locale }) {
554
589
  const React = getReact();
555
- const { useState, useEffect, useCallback } = React;
590
+ const { useState, useEffect, useCallback, useRef } = React;
556
591
  const run = useTestResults();
557
592
  const [state, setState] = useState({
558
593
  loading: true,
@@ -562,6 +597,8 @@
562
597
  const [optionalCols, setOptionalCols] = useState({});
563
598
  const [tooltip, setTooltip] = useState(null);
564
599
  const [localeTick, setLocaleTick] = useState(0);
600
+ const tRef = useRef(t);
601
+ tRef.current = t;
565
602
  useEffect(() => {
566
603
  if (!locale || typeof locale.subscribe !== "function") return;
567
604
  return locale.subscribe(() => setLocaleTick((n) => n + 1));
@@ -574,7 +611,7 @@
574
611
  }));
575
612
  try {
576
613
  const json = await fetchModelList();
577
- if (!json.ok) throw new Error(json.error || t("unknownError"));
614
+ if (!json.ok) throw new Error(json.error || tRef.current("unknownError"));
578
615
  setState({
579
616
  loading: false,
580
617
  error: null,
@@ -587,10 +624,23 @@
587
624
  data: null
588
625
  });
589
626
  }
590
- }, [t]);
627
+ }, []);
591
628
  useEffect(() => {
592
629
  loadData();
593
630
  }, [loadData]);
631
+ useEffect(() => {
632
+ if (state.data?.ok && state.data.models) run.prune(new Set(state.data.models.map((m) => m.key)));
633
+ }, [state.data, run.prune]);
634
+ useEffect(() => {
635
+ if (!tooltip) return;
636
+ const close = () => setTooltip(null);
637
+ window.addEventListener("scroll", close, true);
638
+ window.addEventListener("resize", close);
639
+ return () => {
640
+ window.removeEventListener("scroll", close, true);
641
+ window.removeEventListener("resize", close);
642
+ };
643
+ }, [tooltip]);
594
644
  const toggleCol = (key) => {
595
645
  setOptionalCols((s) => ({
596
646
  ...s,
@@ -613,15 +663,13 @@
613
663
  const models = data.models || [];
614
664
  const activeLang = locale && typeof locale.getLocale === "function" ? locale.getLocale().active : "zh";
615
665
  const updatedAt = new Date(data.updatedAt || Date.now()).toLocaleString(activeLang === "en" ? "en-US" : "zh-CN");
616
- const validKeys = new Set(models.map((m) => m.key));
617
666
  const stats = {
618
667
  ok: 0,
619
668
  fail: 0,
620
669
  skip: 0
621
670
  };
622
- for (const k in run.results) {
623
- if (!validKeys.has(k)) continue;
624
- const s = run.results[k].status;
671
+ for (const m of models) {
672
+ const s = run.results[m.key]?.status;
625
673
  if (s === "ok") stats.ok++;
626
674
  else if (s === "fail") stats.fail++;
627
675
  else if (s === "skip") stats.skip++;
@@ -665,7 +713,7 @@
665
713
  style: STYLES.th
666
714
  }, t(col.label))))), /* @__PURE__ */ React.createElement("tbody", null, models.map((row, i) => {
667
715
  const tr = run.results[row.key] || { status: "idle" };
668
- return /* @__PURE__ */ React.createElement("tr", { key: (row.modelId || "") + i }, visibleCols.map((col) => {
716
+ return /* @__PURE__ */ React.createElement("tr", { key: row.key }, visibleCols.map((col) => {
669
717
  if (col.key === "provider") {
670
718
  const span = providerSpan[i];
671
719
  if (span === void 0) return null;
@@ -714,7 +762,8 @@
714
762
  */
715
763
  function registerSettingsNavIcon(label) {
716
764
  let disposed = false;
717
- const sync = () => {
765
+ let scheduled = false;
766
+ const run = () => {
718
767
  if (disposed) return;
719
768
  const currentLabel = label().trim();
720
769
  const buttons = document.querySelectorAll("[role=\"dialog\"] nav button");
@@ -724,7 +773,15 @@
724
773
  else button.removeAttribute(SETTINGS_NAV_MARKER);
725
774
  }
726
775
  };
727
- sync();
776
+ const sync = () => {
777
+ if (scheduled || disposed) return;
778
+ scheduled = true;
779
+ requestAnimationFrame(() => {
780
+ scheduled = false;
781
+ run();
782
+ });
783
+ };
784
+ run();
728
785
  const observer = new MutationObserver(sync);
729
786
  observer.observe(document.body, {
730
787
  childList: true,
package/dist/index.js CHANGED
@@ -47,68 +47,77 @@ function toPublicRow(r) {
47
47
  }
48
48
  function collectModels(cfg) {
49
49
  const rows = [];
50
+ if (!cfg || typeof cfg !== "object") return rows;
50
51
  const piAi = cfg["llm-pi-ai"];
51
52
  if (piAi && typeof piAi === "object") {
52
53
  const providers = piAi.providers || {};
53
54
  for (const [route, p] of Object.entries(providers)) {
54
55
  if (!p || typeof p !== "object") continue;
55
56
  const models = Array.isArray(p.models) ? p.models : [];
56
- for (const m of models) rows.push({
57
- key: `${route}/${m.id || ""}`,
58
- provider: route,
59
- displayName: p.displayName || route,
60
- modelId: m.id || "",
61
- modelName: m.name || m.id || "",
62
- contextWindow: m.contextWindow ?? p.defaultContextWindow ?? "-",
63
- maxTokens: m.maxTokens ?? p.defaultMaxTokens ?? "-",
64
- input: Array.isArray(m.input) ? m.input.join("/") : (p.defaultInput || ["text"]).join("/"),
65
- api: p.api || "-",
66
- baseURL: p.baseURL || "-",
67
- apiKeyEnv: p.apiKeyEnv || ""
68
- });
57
+ for (const m of models) {
58
+ if (!m || typeof m !== "object" || !m.id) continue;
59
+ rows.push({
60
+ key: `pi-ai/${route}/${m.id}`,
61
+ provider: route,
62
+ displayName: p.displayName || route,
63
+ modelId: m.id || "",
64
+ modelName: m.name || m.id || "",
65
+ contextWindow: m.contextWindow ?? p.defaultContextWindow ?? "-",
66
+ maxTokens: m.maxTokens ?? p.defaultMaxTokens ?? "-",
67
+ input: Array.isArray(m.input) ? m.input.join("/") : (p.defaultInput || ["text"]).join("/"),
68
+ api: p.api || "-",
69
+ baseURL: p.baseURL || "-",
70
+ apiKeyEnv: p.apiKeyEnv || ""
71
+ });
72
+ }
69
73
  }
70
74
  }
71
75
  const ds = cfg["llm-deepseek"];
72
76
  if (ds && typeof ds === "object") {
73
77
  const models = Array.isArray(ds.models) ? ds.models : [];
74
- for (const m of models) rows.push({
75
- key: `deepseek/${m.id || ""}`,
76
- provider: "deepseek",
77
- displayName: "DeepSeek",
78
- modelId: m.id || "",
79
- modelName: m.name || m.id || "",
80
- contextWindow: m.contextWindow ?? "-",
81
- maxTokens: m.maxTokens ?? "-",
82
- input: Array.isArray(m.input) ? m.input.join("/") : "text",
83
- api: "deepseek",
84
- baseURL: "https://api.deepseek.com",
85
- apiKeyEnv: ds.apiKeyEnv || "DEEPSEEK_API_KEY"
86
- });
78
+ for (const m of models) {
79
+ if (!m || typeof m !== "object" || !m.id) continue;
80
+ rows.push({
81
+ key: `deepseek/${m.id}`,
82
+ provider: "deepseek",
83
+ displayName: "DeepSeek",
84
+ modelId: m.id || "",
85
+ modelName: m.name || m.id || "",
86
+ contextWindow: m.contextWindow ?? "-",
87
+ maxTokens: m.maxTokens ?? "-",
88
+ input: Array.isArray(m.input) ? m.input.join("/") : "text",
89
+ api: "deepseek",
90
+ baseURL: "https://api.deepseek.com",
91
+ apiKeyEnv: ds.apiKeyEnv || "DEEPSEEK_API_KEY"
92
+ });
93
+ }
87
94
  }
88
95
  return rows;
89
96
  }
90
97
  //#endregion
91
98
  //#region src/host/markdown.ts
92
99
  function renderMarkdownTable(rows) {
100
+ const settingsPath = join(dshHome(), "settings.yaml");
93
101
  if (rows.length === 0) return [
94
- "当前 settings.yaml 中未配置任何模型。",
102
+ "当前 settings.yaml 中未配置任何模型。/ No models are configured in settings.yaml.",
95
103
  "",
96
104
  "请在 Web UI 的 设置 → 模型 中添加 DeepSeek API Key 或自定义提供商。",
97
- `配置文件位置:${join(dshHome(), "settings.yaml")}`
105
+ "Please add a DeepSeek API key or a custom provider in Web UI → Settings → Models.",
106
+ `配置文件位置 / Config file: ${settingsPath}`
98
107
  ].join("\n");
99
- const header = "| # | Provider | 模型 ID | 名称 | 上下文窗口 | 最大输出 | 输入模态 | API 协议 | BaseURL |";
100
- const sep = "|---|----------|---------|------|-----------|---------|---------|---------|---------|";
108
+ const header = "| # | Provider | Model ID | Name | Context Window | Max Output | Input | API | BaseURL |";
109
+ const sep = "|---|----------|----------|------|----------------|------------|-------|-----|---------|";
101
110
  const lines = rows.map((r, i) => {
102
111
  return `| ${i + 1} | ${r.provider} | ${r.modelId} | ${r.modelName} | ${r.contextWindow} | ${r.maxTokens} | ${r.input} | ${r.api} | ${r.baseURL} |`;
103
112
  });
104
113
  return [
105
- `已配置 **${rows.length}** 个模型:`,
114
+ `已配置 **${rows.length}** 个模型 / **${rows.length}** model(s) configured:`,
106
115
  "",
107
116
  header,
108
117
  sep,
109
118
  ...lines,
110
119
  "",
111
- `_来源:${join(dshHome(), "settings.yaml")}_`
120
+ `_来源 / Source: ${settingsPath}_`
112
121
  ].join("\n");
113
122
  }
114
123
  //#endregion
@@ -119,6 +128,27 @@ function sendJson(res, code, obj) {
119
128
  res.setHeader("Content-Type", "application/json; charset=utf-8");
120
129
  res.end(JSON.stringify(obj));
121
130
  }
131
+ const LOCAL_HOSTNAMES = /* @__PURE__ */ new Set([
132
+ "localhost",
133
+ "127.0.0.1",
134
+ "[::1]",
135
+ "::1",
136
+ "0.0.0.0"
137
+ ]);
138
+ /**
139
+ * 浏览器跨站请求会携带 Origin 头:要求其指向本机(无 Origin 的请求——
140
+ * curl / 服务间调用——放行)。与 readJsonBody 的 Content-Type 门卫叠加,
141
+ * 构成写路由(POST /test)的双层 CSRF/SSRF 缓解。
142
+ */
143
+ function isLocalOrigin(req) {
144
+ const origin = String(req.headers.origin ?? "");
145
+ if (!origin) return true;
146
+ try {
147
+ return LOCAL_HOSTNAMES.has(new URL(origin).hostname.toLowerCase());
148
+ } catch {
149
+ return false;
150
+ }
151
+ }
122
152
  /** 读 JSON body(限 64KB),且必须是 application/json(兼作 CSRF 门卫)。 */
123
153
  async function readJsonBody(req) {
124
154
  if (!String(req.headers["content-type"] ?? "").includes("application/json")) throw new Error("Content-Type 必须是 application/json");
@@ -134,11 +164,47 @@ async function readJsonBody(req) {
134
164
  }
135
165
  //#endregion
136
166
  //#region src/host/model-test.ts
167
+ /** 错误体最多读这么多字节就提前取消(展示时也只取前 300 字符) */
168
+ const MAX_ERROR_BODY_BYTES = 1024;
169
+ /** 成功响应体较大(含 usage 等),最多读 4KB 用于成功校验 */
170
+ const SUCCESS_BODY_MAX_CHARS = 4096;
171
+ const PROBE_TIMEOUT_MS = 1e4;
172
+ /** 读取响应体前若干字符:超大响应(如 HTML 错误页)不整体载入内存。 */
173
+ async function readBodySnippet(resp, maxChars) {
174
+ if (Number(resp.headers.get("content-length") || 0) > MAX_ERROR_BODY_BYTES) return "";
175
+ const reader = resp.body?.getReader();
176
+ if (!reader) return "";
177
+ const decoder = new TextDecoder();
178
+ let text = "";
179
+ try {
180
+ while (text.length < maxChars) {
181
+ const { done, value } = await reader.read();
182
+ if (done) break;
183
+ text += decoder.decode(value, { stream: true });
184
+ }
185
+ text += decoder.decode();
186
+ } finally {
187
+ await reader.cancel().catch(() => {});
188
+ }
189
+ return text.slice(0, maxChars);
190
+ }
191
+ /** 校验 200 响应体确实是该协议的成功应答(部分网关 200 但 body 是错误对象)。 */
192
+ function validateSuccessBody(_api, text) {
193
+ if (!text.trim()) return "响应体为空";
194
+ let json;
195
+ try {
196
+ json = JSON.parse(text);
197
+ } catch {
198
+ return "响应体不是合法 JSON";
199
+ }
200
+ if (json?.error) return `响应体含错误:${(typeof json.error === "string" ? json.error : JSON.stringify(json.error)).slice(0, 200)}`;
201
+ if (!Array.isArray(json?.choices)) return "响应缺少 choices 字段";
202
+ }
137
203
  /** 发起最小测试请求(max_tokens=1,超时 10s),返回结果对象。 */
138
204
  async function probeModel(row, apiKey) {
139
205
  const url = row.baseURL.replace(/\/$/, "") + "/chat/completions";
140
206
  const controller = new AbortController();
141
- const timer = setTimeout(() => controller.abort(), 1e4);
207
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
142
208
  const start = Date.now();
143
209
  try {
144
210
  const resp = await fetch(url, {
@@ -158,29 +224,41 @@ async function probeModel(row, apiKey) {
158
224
  }),
159
225
  signal: controller.signal
160
226
  });
161
- clearTimeout(timer);
162
227
  const latency = Date.now() - start;
163
- if (resp.ok) return {
164
- status: "ok",
165
- latency
228
+ const snippet = await readBodySnippet(resp, resp.ok ? SUCCESS_BODY_MAX_CHARS : 300).catch(() => "");
229
+ if (!resp.ok) return {
230
+ status: "fail",
231
+ latency,
232
+ error: `HTTP ${resp.status}: ${snippet.slice(0, 300)}`
166
233
  };
167
- const text = await resp.text().catch(() => "");
168
- return {
234
+ const problem = validateSuccessBody(row.api, snippet);
235
+ return problem ? {
169
236
  status: "fail",
170
237
  latency,
171
- error: `HTTP ${resp.status}: ${text.slice(0, 300)}`
238
+ error: problem
239
+ } : {
240
+ status: "ok",
241
+ latency
172
242
  };
173
243
  } catch (e) {
174
- clearTimeout(timer);
175
244
  return {
176
245
  status: "fail",
177
246
  latency: Date.now() - start,
178
- error: e?.name === "AbortError" ? "超时(10s)" : e?.message || String(e)
247
+ error: e?.name === "AbortError" ? `超时(${PROBE_TIMEOUT_MS / 1e3}s)` : e?.message || String(e)
179
248
  };
249
+ } finally {
250
+ clearTimeout(timer);
180
251
  }
181
252
  }
182
253
  function createTestRouteHandler(resolveApiKey) {
183
254
  return async function handler(req, res) {
255
+ if (!isLocalOrigin(req)) {
256
+ sendJson(res, 403, {
257
+ ok: false,
258
+ error: "拒绝非本地来源的请求"
259
+ });
260
+ return;
261
+ }
184
262
  let key;
185
263
  try {
186
264
  key = (await readJsonBody(req)).key;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model-health",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "DSH plugin that lists all configured models with availability/latency testing, by reading $DSH_HOME/settings.yaml.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,7 +33,8 @@
33
33
  "scripts": {
34
34
  "build": "tsdown",
35
35
  "prepare": "pnpm run build",
36
- "typecheck": "tsc -p tsconfig.json --noEmit"
36
+ "typecheck": "tsc -p tsconfig.json --noEmit",
37
+ "test": "vitest run"
37
38
  },
38
39
  "dependencies": {
39
40
  "js-yaml": "^4.1.0"
@@ -49,6 +50,12 @@
49
50
  "@types/node": "^22.10.0",
50
51
  "@types/react": "^19.2.18",
51
52
  "tsdown": "^0.22.14",
52
- "typescript": "^5.6.0"
53
+ "typescript": "^5.6.0",
54
+ "vitest": "^3.2.4"
55
+ },
56
+ "pnpm": {
57
+ "onlyBuiltDependencies": [
58
+ "esbuild"
59
+ ]
53
60
  }
54
61
  }