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 +147 -52
- package/dist/index.js +73 -51
- package/package.json +5 -1
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: "
|
|
187
|
-
{ key: "modelId", label: "
|
|
188
|
-
{ key: "modelName", label: "
|
|
189
|
-
{ key: "contextWindow", label: "
|
|
190
|
-
{ key: "maxTokens", label: "
|
|
191
|
-
{ key: "input", label: "
|
|
192
|
-
{ key: "api", label: "
|
|
193
|
-
{ key: "baseURL", label: "
|
|
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
|
-
|
|
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: { [
|
|
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 (
|
|
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
|
|
290
|
-
const queue = [...indices];
|
|
371
|
+
const queue = data.models.map((m) => m.key);
|
|
291
372
|
|
|
292
373
|
// 更新单个结果并同步到 localStorage
|
|
293
|
-
function updateResult(
|
|
374
|
+
function updateResult(key, result) {
|
|
294
375
|
setTestResults((s) => {
|
|
295
|
-
const next = { ...s, [
|
|
376
|
+
const next = { ...s, [key]: result };
|
|
296
377
|
saveStoredResults(next);
|
|
297
378
|
return next;
|
|
298
379
|
});
|
|
299
380
|
}
|
|
300
381
|
|
|
301
|
-
async function runOne(
|
|
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({
|
|
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(
|
|
392
|
+
updateResult(key, { status: "fail", error: json.error });
|
|
312
393
|
} else {
|
|
313
|
-
updateResult(
|
|
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(
|
|
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
|
|
332
|
-
if (
|
|
333
|
-
await runOne(
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
"
|
|
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[
|
|
475
|
-
const
|
|
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 =
|
|
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 =
|
|
178
|
+
const cfg = readSettingsCached();
|
|
134
179
|
const rows = collectModels(cfg);
|
|
135
|
-
|
|
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
|
-
|
|
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: { "
|
|
158
|
-
//
|
|
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
|
-
|
|
170
|
-
if (typeof
|
|
171
|
-
throw new Error('
|
|
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.
|
|
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 =
|
|
214
|
+
const cfg = readSettingsCached();
|
|
184
215
|
const rows = collectModels(cfg);
|
|
185
|
-
const found = rows
|
|
216
|
+
const found = rows.find((r) => r.key === key);
|
|
186
217
|
if (!found) {
|
|
187
|
-
res
|
|
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.
|
|
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
|
|
203
|
-
|
|
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
|
|
224
|
-
|
|
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
|
|
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
|
|
259
|
-
|
|
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
|
|
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.
|
|
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": {
|