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.
- package/README.md +1 -1
- package/dist/index.js +90 -12
- 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
|
-
-
|
|
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,
|
|
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 (
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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 ?
|
|
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