dsh-model-health 0.1.3 → 0.2.0

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/dist/client.js CHANGED
@@ -7,6 +7,9 @@
7
7
  // 模型列表 JSON,然后用 React 直接渲染表格。
8
8
  // 支持"测试全部":并发 POST /api/model-health/test,逐个更新测试状态。
9
9
  //
10
+ // 多语言:通过官方 locale 服务注册 zh/en 词典(与 dsh-plugin-mgr 同模式),
11
+ // 语言跟随 harness 设置(设置 → 通用 → 语言),切换语言即时生效。
12
+ //
10
13
  window.__ModuleLoader__.load({
11
14
  id: "dsh-model-health",
12
15
  factory: (require) => {
@@ -16,6 +19,65 @@ window.__ModuleLoader__.load({
16
19
  const React = require("react");
17
20
  const { useState, useEffect, useCallback } = React;
18
21
 
22
+ // ── 多语言词典(locale 服务,语言跟随 harness)─────────────────────────
23
+ const NS = "dsh-model-health";
24
+ const zh = {
25
+ tab: "模型健康",
26
+ title: "已配置模型",
27
+ updatedAt: "更新时间:",
28
+ testAll: "测试全部",
29
+ testingBtn: "测试中...",
30
+ showCols: "显示列:",
31
+ loading: "加载中...",
32
+ loadError: "读取模型列表失败:",
33
+ retry: "重试",
34
+ unknownError: "未知错误",
35
+ empty: "未配置任何模型,请在 设置 → 模型 中添加。",
36
+ colProvider: "Provider",
37
+ colModelId: "模型 ID",
38
+ colName: "名称",
39
+ colContextWindow: "上下文窗口",
40
+ colMaxTokens: "最大输出",
41
+ colInput: "输入模态",
42
+ colApi: "API 协议",
43
+ colBaseURL: "BaseURL",
44
+ colStatus: "状态",
45
+ colLatency: "延迟",
46
+ statusIdle: "未测试",
47
+ statusTesting: "测试中",
48
+ statusOk: "可用",
49
+ statusFail: "不可用",
50
+ statusSkip: "跳过",
51
+ };
52
+ const en = {
53
+ tab: "Model Health",
54
+ title: "Configured Models",
55
+ updatedAt: "Updated: ",
56
+ testAll: "Test All",
57
+ testingBtn: "Testing...",
58
+ showCols: "Columns: ",
59
+ loading: "Loading...",
60
+ loadError: "Failed to load model list: ",
61
+ retry: "Retry",
62
+ unknownError: "Unknown error",
63
+ empty: "No models configured. Add one in Settings → Models.",
64
+ colProvider: "Provider",
65
+ colModelId: "Model ID",
66
+ colName: "Name",
67
+ colContextWindow: "Context Window",
68
+ colMaxTokens: "Max Output",
69
+ colInput: "Modalities",
70
+ colApi: "API Protocol",
71
+ colBaseURL: "BaseURL",
72
+ colStatus: "Status",
73
+ colLatency: "Latency",
74
+ statusIdle: "Untested",
75
+ statusTesting: "Testing",
76
+ statusOk: "Available",
77
+ statusFail: "Unavailable",
78
+ statusSkip: "Skipped",
79
+ };
80
+
19
81
  // 内联样式(适配 DSH 明暗主题的 CSS 变量)
20
82
  const STYLES = {
21
83
  wrap: {
@@ -181,27 +243,27 @@ window.__ModuleLoader__.load({
181
243
  },
182
244
  };
183
245
 
184
- // 列定义:optional=true 的列默认不显示,由多选框控制
246
+ // 列定义:optional=true 的列默认不显示,由多选框控制;label 为词典 key
185
247
  const COLUMNS = [
186
- { key: "provider", label: "Provider" },
187
- { key: "modelId", label: "模型 ID", code: true, optional: true },
188
- { key: "modelName", label: "名称" },
189
- { key: "contextWindow", label: "上下文窗口", optional: true },
190
- { key: "maxTokens", label: "最大输出", optional: true },
191
- { key: "input", label: "输入模态", optional: true },
192
- { key: "api", label: "API 协议", optional: true },
193
- { key: "baseURL", label: "BaseURL", code: true, optional: true },
194
- { key: "_status", label: "状态" },
195
- { key: "_latency", label: "延迟" },
248
+ { key: "provider", label: "colProvider" },
249
+ { key: "modelId", label: "colModelId", code: true, optional: true },
250
+ { key: "modelName", label: "colName" },
251
+ { key: "contextWindow", label: "colContextWindow", optional: true },
252
+ { key: "maxTokens", label: "colMaxTokens", optional: true },
253
+ { key: "input", label: "colInput", optional: true },
254
+ { key: "api", label: "colApi", optional: true },
255
+ { key: "baseURL", label: "colBaseURL", code: true, optional: true },
256
+ { key: "_status", label: "colStatus" },
257
+ { key: "_latency", label: "colLatency" },
196
258
  ];
197
259
 
198
- // 状态标签映射
260
+ // 状态标签映射(值为词典 key)
199
261
  const STATUS_LABEL = {
200
- idle: "未测试",
201
- testing: "测试中",
202
- ok: "可用",
203
- fail: "不可用",
204
- skip: "跳过",
262
+ idle: "statusIdle",
263
+ testing: "statusTesting",
264
+ ok: "statusOk",
265
+ fail: "statusFail",
266
+ skip: "statusSkip",
205
267
  };
206
268
  const STATUS_STYLE = {
207
269
  idle: STYLES.badgeIdle,
@@ -215,6 +277,8 @@ window.__ModuleLoader__.load({
215
277
  * 模型列表 section 组件。
216
278
  * fetch /api/model-health/json 获取数据 → React 渲染表格。
217
279
  * "测试全部" → 并发 POST /api/model-health/test,逐个更新状态。
280
+ * props: { t, locale } — t 为绑定了词典命名空间的翻译函数,
281
+ * locale 用于订阅语言变化触发重渲染。
218
282
  */
219
283
  // localStorage 持久化测试结果的 key
220
284
  const STORAGE_KEY = "dsh-model-health:test-results";
@@ -222,7 +286,17 @@ window.__ModuleLoader__.load({
222
286
  function loadStoredResults() {
223
287
  try {
224
288
  const raw = localStorage.getItem(STORAGE_KEY);
225
- return raw ? JSON.parse(raw) : {};
289
+ const parsed = raw ? JSON.parse(raw) : {};
290
+ const cleaned = {};
291
+ // 清理:残留的 testing(测试中刷新页面)归一为 idle;
292
+ // 旧版按数组下标存的纯数字 key 直接丢弃
293
+ for (const k in parsed) {
294
+ if (/^\d+$/.test(k)) continue;
295
+ const v = parsed[k];
296
+ if (!v || typeof v !== "object") continue;
297
+ cleaned[k] = v.status === "testing" ? { status: "idle" } : v;
298
+ }
299
+ return cleaned;
226
300
  } catch (e) {
227
301
  return {};
228
302
  }
@@ -236,13 +310,14 @@ window.__ModuleLoader__.load({
236
310
  }
237
311
  }
238
312
 
239
- function ModelListSection() {
313
+ function ModelListSection({ t, locale }) {
240
314
  const [state, setState] = useState({
241
315
  loading: true,
242
316
  error: null,
243
317
  data: null,
244
318
  });
245
- // testResults: { [index]: { status, latency?, error? } }
319
+ // testResults: { [modelKey]: { status, latency?, error? } }
320
+ // key 为 provider/modelId(与 host 侧一致),settings 增删/排序不错位;
246
321
  // 初始值从 localStorage 读取,实现跨刷新持久化
247
322
  const [testResults, setTestResults] = useState(loadStoredResults);
248
323
  const [testing, setTesting] = useState(false);
@@ -251,6 +326,13 @@ window.__ModuleLoader__.load({
251
326
  const [optionalCols, setOptionalCols] = useState({});
252
327
  // hover tooltip 状态:{ index, error } | null
253
328
  const [tooltip, setTooltip] = useState(null);
329
+ // 语言切换时强制重渲染(t 每次调用读取当前激活词典)
330
+ const [localeTick, setLocaleTick] = useState(0);
331
+
332
+ useEffect(() => {
333
+ if (!locale || typeof locale.subscribe !== "function") return;
334
+ return locale.subscribe(() => setLocaleTick((n) => n + 1));
335
+ }, [locale]);
254
336
 
255
337
  const loadData = useCallback(async () => {
256
338
  setState((s) => ({ ...s, loading: true, error: null }));
@@ -258,7 +340,7 @@ window.__ModuleLoader__.load({
258
340
  const resp = await fetch("/api/model-health/json", { cache: "no-store" });
259
341
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
260
342
  const json = await resp.json();
261
- if (!json.ok) throw new Error(json.error || "未知错误");
343
+ if (!json.ok) throw new Error(json.error || t("unknownError"));
262
344
  setState({ loading: false, error: null, data: json });
263
345
  // 不清空 testResults,保留上次测试结果
264
346
  } catch (e) {
@@ -277,47 +359,46 @@ window.__ModuleLoader__.load({
277
359
  const total = data.models.length;
278
360
  setTesting(true);
279
361
  setProgress({ done: 0, total });
280
- // 初始化所有为 testing
362
+ // 初始化所有为 testing(按 provider/modelId key)
281
363
  const init = {};
282
- for (let i = 0; i < total; i++) init[i] = { status: "testing" };
364
+ for (const m of data.models) init[m.key] = { status: "testing" };
283
365
  setTestResults(init);
284
366
  saveStoredResults(init);
285
367
 
286
368
  let done = 0;
287
369
  // 并发测试,但限制并发数避免压垮 host/网络
288
370
  const CONCURRENCY = 6;
289
- const indices = Array.from({ length: total }, (_, i) => i);
290
- const queue = [...indices];
371
+ const queue = data.models.map((m) => m.key);
291
372
 
292
373
  // 更新单个结果并同步到 localStorage
293
- function updateResult(idx, result) {
374
+ function updateResult(key, result) {
294
375
  setTestResults((s) => {
295
- const next = { ...s, [idx]: result };
376
+ const next = { ...s, [key]: result };
296
377
  saveStoredResults(next);
297
378
  return next;
298
379
  });
299
380
  }
300
381
 
301
- async function runOne(idx) {
382
+ async function runOne(key) {
302
383
  try {
303
384
  const resp = await fetch("/api/model-health/test", {
304
385
  method: "POST",
305
386
  headers: { "Content-Type": "application/json" },
306
- body: JSON.stringify({ index: idx }),
387
+ body: JSON.stringify({ key }),
307
388
  cache: "no-store",
308
389
  });
309
390
  const json = await resp.json();
310
391
  if (!json.ok) {
311
- updateResult(idx, { status: "fail", error: json.error });
392
+ updateResult(key, { status: "fail", error: json.error });
312
393
  } else {
313
- updateResult(idx, {
394
+ updateResult(key, {
314
395
  status: json.status || "fail",
315
396
  latency: json.latency,
316
397
  error: json.error,
317
398
  });
318
399
  }
319
400
  } catch (e) {
320
- updateResult(idx, { status: "fail", error: e.message });
401
+ updateResult(key, { status: "fail", error: e.message });
321
402
  } finally {
322
403
  done++;
323
404
  setProgress({ done, total });
@@ -328,9 +409,9 @@ window.__ModuleLoader__.load({
328
409
  // 限制并发的 worker pool
329
410
  async function worker() {
330
411
  while (queue.length > 0) {
331
- const idx = queue.shift();
332
- if (idx === undefined) break;
333
- await runOne(idx);
412
+ const key = queue.shift();
413
+ if (key === undefined) break;
414
+ await runOne(key);
334
415
  }
335
416
  }
336
417
  const workers = Array.from(
@@ -344,25 +425,32 @@ window.__ModuleLoader__.load({
344
425
 
345
426
  if (state.loading) {
346
427
  return h("div", { style: STYLES.wrap },
347
- h("div", { style: STYLES.loading }, "加载中...")
428
+ h("div", { style: STYLES.loading }, t("loading"))
348
429
  );
349
430
  }
350
431
 
351
432
  if (state.error) {
352
433
  return h("div", { style: STYLES.wrap },
353
434
  h("div", { style: STYLES.error },
354
- "读取模型列表失败:", state.error
435
+ t("loadError"), state.error
355
436
  ),
356
437
  h("button", {
357
438
  style: STYLES.btn,
358
439
  onClick: loadData,
359
- }, "重试")
440
+ }, t("retry"))
360
441
  );
361
442
  }
362
443
 
363
444
  const data = state.data || { models: [], count: 0, source: "", updatedAt: "" };
364
445
  const models = data.models || [];
365
- const updatedAt = new Date(data.updatedAt || Date.now()).toLocaleString("zh-CN");
446
+ // 日期格式跟随当前语言(zh → zh-CN,en → en-US)
447
+ const activeLang =
448
+ locale && typeof locale.getLocale === "function"
449
+ ? locale.getLocale().active
450
+ : "zh";
451
+ const updatedAt = new Date(data.updatedAt || Date.now()).toLocaleString(
452
+ activeLang === "en" ? "en-US" : "zh-CN"
453
+ );
366
454
 
367
455
  // 统计测试结果
368
456
  const stats = { ok: 0, fail: 0, skip: 0, testing: 0 };
@@ -386,12 +474,12 @@ window.__ModuleLoader__.load({
386
474
 
387
475
  return h("div", { style: STYLES.wrap },
388
476
  h("div", { style: STYLES.head },
389
- h("h2", { style: STYLES.title }, "已配置模型"),
477
+ h("h2", { style: STYLES.title }, t("title")),
390
478
  h("span", { style: STYLES.count }, data.count || models.length)
391
479
  ),
392
480
  h("div", { style: STYLES.toolbar },
393
481
  h("span", { style: STYLES.meta },
394
- "更新时间:", updatedAt
482
+ t("updatedAt"), updatedAt
395
483
  ),
396
484
  // 测试统计
397
485
  (stats.ok + stats.fail + stats.skip > 0) && h("span", { style: STYLES.progress },
@@ -410,12 +498,12 @@ window.__ModuleLoader__.load({
410
498
  },
411
499
  disabled: testing || models.length === 0,
412
500
  onClick: testAll,
413
- }, testing ? "测试中..." : "测试全部")
501
+ }, testing ? t("testingBtn") : t("testAll"))
414
502
  ),
415
503
  // 可选列 toggle 按钮组
416
504
  h("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" } },
417
505
  h("span", { style: { fontSize: 12, color: "var(--dsw-alias-label-tertiary, #888)" } },
418
- "显示列:"
506
+ t("showCols")
419
507
  ),
420
508
  optionalColumns.map((col) => {
421
509
  const active = !!optionalCols[col.key];
@@ -439,12 +527,12 @@ window.__ModuleLoader__.load({
439
527
  ? "var(--dsw-alias-label-primary-foreground, #fff)"
440
528
  : "var(--dsw-alias-label-secondary, #555)",
441
529
  },
442
- }, col.label);
530
+ }, t(col.label));
443
531
  })
444
532
  ),
445
533
  models.length === 0
446
534
  ? h("div", { style: STYLES.center },
447
- "未配置任何模型,请在 设置 → 模型 中添加。"
535
+ t("empty")
448
536
  )
449
537
  : h("div", { style: STYLES.tableWrap },
450
538
  h("table", { style: STYLES.table },
@@ -454,7 +542,7 @@ window.__ModuleLoader__.load({
454
542
  h("th", {
455
543
  key: col.key,
456
544
  style: STYLES.th,
457
- }, col.label)
545
+ }, t(col.label))
458
546
  )
459
547
  )
460
548
  ),
@@ -471,8 +559,9 @@ window.__ModuleLoader__.load({
471
559
  providerSpan[start] = k - start;
472
560
  }
473
561
  return models.map((row, i) => {
474
- const tr = testResults[i] || { status: "idle" };
475
- const statusLabel = STATUS_LABEL[tr.status] || tr.status;
562
+ const tr = testResults[row.key] || { status: "idle" };
563
+ const labelKey = STATUS_LABEL[tr.status];
564
+ const statusLabel = labelKey ? t(labelKey) : tr.status;
476
565
  const statusStyle = STATUS_STYLE[tr.status] || STYLES.badgeIdle;
477
566
  const latencyText = tr.latency != null ? `${tr.latency}ms` : "-";
478
567
  return h("tr", { key: (row.modelId || "") + i },
@@ -566,22 +655,28 @@ window.__ModuleLoader__.load({
566
655
  );
567
656
  }
568
657
 
569
- // client 侧依赖:slots(注册 UI slot 的服务)
570
- const inject = ["slots"];
658
+ // client 侧依赖:slots(注册 UI slot 的服务)+ locale(多语言词典)
659
+ const inject = ["slots", "locale"];
571
660
 
572
661
  /**
573
662
  * @param {import('@deepseek-ai/dsh-client-runtime/client').ClientContext} ctx
574
663
  */
575
664
  function apply(ctx) {
665
+ // 注册 zh/en 词典;t 每次调用读取当前激活语言
666
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-model-health: dictionaries");
667
+ const t = ctx.locale.bind(NS);
668
+ const h = React.createElement;
669
+
576
670
  ctx.slots.inject("settings.section", () =>
577
671
  ctx.slots.register(
578
672
  {
579
673
  name: "settings.section",
580
674
  id: "model-health",
581
675
  order: 50,
582
- label: "模型健康",
676
+ label: () => t("tab"),
677
+ locale: NS,
583
678
  },
584
- ModelListSection
679
+ () => h(ModelListSection, { t, locale: ctx.locale })
585
680
  )
586
681
  );
587
682
  }
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
- import { readFileSync } from 'node:fs';
2
+ import { readFileSync, statSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { load as parseYaml } from 'js-yaml';
@@ -32,6 +32,23 @@ function readSettings() {
32
32
  const cfg = parseYaml(raw);
33
33
  return cfg && typeof cfg === 'object' ? cfg : {};
34
34
  }
35
+ /** 按 mtime 缓存解析结果:并发测试时同一份 settings 只解析一次,文件变了自动失效。 */
36
+ let settingsCache = null;
37
+ function readSettingsCached() {
38
+ const settingsPath = join(dshHome(), 'settings.yaml');
39
+ try {
40
+ const mtimeMs = statSync(settingsPath).mtimeMs;
41
+ if (settingsCache && settingsCache.mtimeMs === mtimeMs)
42
+ return settingsCache.cfg;
43
+ const cfg = readSettings();
44
+ settingsCache = { mtimeMs, cfg };
45
+ return cfg;
46
+ }
47
+ catch {
48
+ settingsCache = null;
49
+ return readSettings();
50
+ }
51
+ }
35
52
  /** 从 settings 配置对象中收集所有已配置的模型。 */
36
53
  export function collectModels(cfg) {
37
54
  const rows = [];
@@ -45,6 +62,7 @@ export function collectModels(cfg) {
45
62
  const models = Array.isArray(p.models) ? p.models : [];
46
63
  for (const m of models) {
47
64
  rows.push({
65
+ key: `${route}/${m.id || ''}`,
48
66
  provider: route,
49
67
  displayName: p.displayName || route,
50
68
  modelId: m.id || '',
@@ -66,6 +84,7 @@ export function collectModels(cfg) {
66
84
  const models = Array.isArray(ds.models) ? ds.models : [];
67
85
  for (const m of models) {
68
86
  rows.push({
87
+ key: `deepseek/${m.id || ''}`,
69
88
  provider: 'deepseek',
70
89
  displayName: 'DeepSeek',
71
90
  modelId: m.id || '',
@@ -106,6 +125,32 @@ function renderMarkdownTable(rows) {
106
125
  `_来源:${join(dshHome(), 'settings.yaml')}_`,
107
126
  ].join('\n');
108
127
  }
128
+ /** 统一 JSON 响应:设状态码 + Content-Type + 序列化输出。 */
129
+ function sendJson(res, code, obj) {
130
+ res.statusCode = code;
131
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
132
+ res.end(JSON.stringify(obj));
133
+ }
134
+ /** 读 JSON body(限 64KB),且必须是 application/json(兼作 CSRF 门卫)。 */
135
+ async function readJsonBody(req) {
136
+ const ct = String(req.headers['content-type'] ?? '');
137
+ if (!ct.includes('application/json')) {
138
+ throw new Error('Content-Type 必须是 application/json');
139
+ }
140
+ let body = '';
141
+ for await (const chunk of req) {
142
+ body += chunk;
143
+ if (body.length > 65536)
144
+ throw new Error('请求体过大');
145
+ }
146
+ if (body === '')
147
+ return {};
148
+ const parsed = JSON.parse(body);
149
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
150
+ throw new Error('请求体必须是 JSON 对象');
151
+ }
152
+ return parsed;
153
+ }
109
154
  export function apply(ctx) {
110
155
  // ── 1. Tool 插件(对话中调用,返回 Markdown 表格)──────────────────
111
156
  ctx.tools.register(defineTool({
@@ -118,7 +163,7 @@ export function apply(ctx) {
118
163
  render: (_args, value) => [{ type: 'text', text: value }],
119
164
  },
120
165
  async execute() {
121
- const cfg = readSettings();
166
+ const cfg = readSettingsCached();
122
167
  return renderMarkdownTable(collectModels(cfg));
123
168
  },
124
169
  }));
@@ -130,80 +175,61 @@ export function apply(ctx) {
130
175
  path: '/api/model-health/json',
131
176
  handler: (_req, res) => {
132
177
  try {
133
- const cfg = readSettings();
178
+ const cfg = readSettingsCached();
134
179
  const rows = collectModels(cfg);
135
- const payload = JSON.stringify({
180
+ sendJson(res, 200, {
136
181
  ok: true,
137
182
  count: rows.length,
138
183
  source: join(dshHome(), 'settings.yaml'),
139
184
  updatedAt: new Date().toISOString(),
140
185
  models: rows.map(toPublicRow),
141
186
  });
142
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
143
- res.end(payload);
144
187
  }
145
188
  catch (e) {
146
- const payload = JSON.stringify({
147
- ok: false,
148
- error: `读取模型列表失败:${e.message}`,
149
- });
150
- res.statusCode = 500;
151
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
152
- res.end(payload);
189
+ sendJson(res, 500, { ok: false, error: `读取模型列表失败:${e.message}` });
153
190
  }
154
191
  },
155
192
  });
156
193
  // ── 3. HTTP 路由(测试单个模型可用性)──────────────────────────────
157
- // POST /api/model-health/test body: { "index": <number> }
158
- // 对该模型发起一次最小 chat completions 请求,返回 { ok, index, status, latency, error? }
194
+ // POST /api/model-health/test body: { "key": "<provider>/<modelId>" }
195
+ // 按 key 定位模型(settings 增删/排序后不错位),
196
+ // 发起一次最小 chat completions 请求,返回 { ok, key, status, latency, error? }
159
197
  ctx.webServer.register({
160
198
  kind: 'exact',
161
199
  path: '/api/model-health/test',
162
200
  handler: async (req, res) => {
163
- // 读取请求体
164
- let body = '';
165
- for await (const chunk of req)
166
- body += chunk;
167
- let index;
201
+ let key;
168
202
  try {
169
- index = JSON.parse(body || '{}').index;
170
- if (typeof index !== 'number' || !Number.isInteger(index) || index < 0) {
171
- throw new Error('index 必须是非负整数');
172
- }
203
+ key = (await readJsonBody(req)).key;
204
+ if (typeof key !== 'string' || key === '')
205
+ throw new Error('key 必须是非空字符串');
173
206
  }
174
207
  catch (e) {
175
- res.statusCode = 400;
176
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
177
- res.end(JSON.stringify({ ok: false, error: `参数错误:${e.message}` }));
208
+ sendJson(res, 400, { ok: false, error: `参数错误:${e.message}` });
178
209
  return;
179
210
  }
180
211
  // 查找模型配置
181
212
  let row;
182
213
  try {
183
- const cfg = readSettings();
214
+ const cfg = readSettingsCached();
184
215
  const rows = collectModels(cfg);
185
- const found = rows[index];
216
+ const found = rows.find((r) => r.key === key);
186
217
  if (!found) {
187
- res.statusCode = 404;
188
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
189
- res.end(JSON.stringify({ ok: false, error: `未找到索引为 ${index} 的模型` }));
218
+ sendJson(res, 404, { ok: false, error: `未找到 key 为 ${key} 的模型(配置可能已变化,请刷新列表)` });
190
219
  return;
191
220
  }
192
221
  row = found;
193
222
  }
194
223
  catch (e) {
195
- res.statusCode = 500;
196
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
197
- res.end(JSON.stringify({ ok: false, error: e.message }));
224
+ sendJson(res, 500, { ok: false, error: e.message });
198
225
  return;
199
226
  }
200
227
  // 仅支持 openai-completions 协议
201
228
  if (row.api !== 'openai-completions' && row.api !== 'deepseek') {
202
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
203
- res.end(JSON.stringify({
204
- ok: true, index, status: 'skip',
229
+ sendJson(res, 200, {
230
+ ok: true, key, status: 'skip',
205
231
  error: `不支持 ${row.api} 协议的测试`,
206
- }));
232
+ });
207
233
  return;
208
234
  }
209
235
  // 获取 API Key
@@ -220,11 +246,10 @@ export function apply(ctx) {
220
246
  }
221
247
  }
222
248
  if (!apiKey) {
223
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
224
- res.end(JSON.stringify({
225
- ok: true, index, status: 'fail',
249
+ sendJson(res, 200, {
250
+ ok: true, key, status: 'fail',
226
251
  error: `未配置 ${row.apiKeyEnv} 的 API Key(请在 设置 → 模型 中添加)`,
227
- }));
252
+ });
228
253
  return;
229
254
  }
230
255
  // 发起最小测试请求(max_tokens=1,超时 10s)
@@ -250,24 +275,21 @@ export function apply(ctx) {
250
275
  clearTimeout(timer);
251
276
  const latency = Date.now() - start;
252
277
  if (resp.ok) {
253
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
254
- res.end(JSON.stringify({ ok: true, index, status: 'ok', latency }));
278
+ sendJson(res, 200, { ok: true, key, status: 'ok', latency });
255
279
  }
256
280
  else {
257
281
  const text = await resp.text().catch(() => '');
258
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
259
- res.end(JSON.stringify({
260
- ok: true, index, status: 'fail', latency,
282
+ sendJson(res, 200, {
283
+ ok: true, key, status: 'fail', latency,
261
284
  error: `HTTP ${resp.status}: ${text.slice(0, 300)}`,
262
- }));
285
+ });
263
286
  }
264
287
  }
265
288
  catch (e) {
266
289
  clearTimeout(timer);
267
290
  const latency = Date.now() - start;
268
291
  const msg = e?.name === 'AbortError' ? '超时(10s)' : (e?.message || String(e));
269
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
270
- res.end(JSON.stringify({ ok: true, index, status: 'fail', latency, error: msg }));
292
+ sendJson(res, 200, { ok: true, key, status: 'fail', latency, error: msg });
271
293
  }
272
294
  },
273
295
  });
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "dsh-model-health",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
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
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/oxlyn/dsh-model-health.git"
9
+ },
6
10
  "type": "module",
7
11
  "main": "./dist/index.js",
8
12
  "exports": {