@leonhardthust/dsh-decision-engine 1.13.0 → 1.14.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/config.example.yml +2 -1
- package/docs/releasing.md +4 -4
- package/lib/client.js +44 -6
- package/lib/index.js +37 -7
- package/lib/stats.js +128 -60
- package/lib/telemetry.js +19 -1
- package/package.json +1 -1
package/config.example.yml
CHANGED
|
@@ -424,7 +424,8 @@ guardFallbackReasoningEffort: ""
|
|
|
424
424
|
#
|
|
425
425
|
# off 完全关闭:一个请求都不发
|
|
426
426
|
# install 仅首次运行上报一次「装了」,之后不再联网
|
|
427
|
-
# daily
|
|
427
|
+
# daily 每天上报一次**上一完整自然日**的决策数(默认;跨日由 6 小时一次
|
|
428
|
+
# 的检查补报,长驻进程不会漏掉)
|
|
428
429
|
#
|
|
429
430
|
# telemetryLevel: daily
|
|
430
431
|
# telemetryEndpoint: "" # 留空用内置端点
|
package/docs/releasing.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# npm 发布
|
|
2
2
|
|
|
3
|
-
发布目标:`@leonhardthust/dsh-decision-engine`,当前版本 `1.
|
|
3
|
+
发布目标:`@leonhardthust/dsh-decision-engine`,当前版本 `1.14.0`,公开包。
|
|
4
4
|
|
|
5
5
|
本分支基于 `zuosus/dsh-decision-engine` 的 MIT 代码,保留原作者与许可证;npm scope、源码仓库、bundle 引用和浏览器主模块 / charts chunk 标识已同步为本分支身份。内部 API、样式标识和用户数据目录保留旧名称以兼容已有配置。不要同时启用原包与本 scoped fork,它们提供相同的服务与页面。
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
- `.github/workflows/ci.yml`:main push、PR、手动运行或发布工作流复用;Ubuntu 24.04 + Node.js 24 + Vite+ 1.6.0。执行 frozen install、完整测试与接线、隔离情景、实际打包和 tarball 检查。失败不会被忽略。
|
|
10
10
|
- `.github/workflows/publish.yml`:每次 `main` push 自动检测版本。先比较整个 push 的 `before` 与当前 manifest,拒绝错误包名、非法或倒退版本;再只读查询 npmjs。当前版本已上传则跳过;当前版本高于 npmjs latest 且未上传则进入完整验证及发布。多提交 push 比较整个 push 前后,不仅比较 HEAD 的父提交。
|
|
11
11
|
- 后续发版只需修改 `package.json` 版本并提交到 main;无需创建 tag 或手动点运行。发布依赖检测和本次提交的完整 CI 均成功,不跳过测试。稳定版本发布到 `latest`,预发布版本自动进入 `next`。
|
|
12
|
-
- 当前 `1.
|
|
12
|
+
- 当前 `1.14.0` 包含 DEC-075:统计账本修复——output token 未知值单独计数(不再当 0)、attempts/requests/degraded 分桶与汇总对账、匿名心跳改报上一完整自然日并按 `day` 归档、删除缓存基线的区间均值插补、节省量明确为「决策请求层估算」、安全拦截只按 authorize 统计。`1.13.0` 的 DEC-074、`1.12.0` 的 DEC-072/073、`1.11.2` 的 never 回退、`1.11.1` 的 DEC-070、`1.11.0` 的审计修复、`1.10.0` 的五项改动依次见 DEC-062 ~ DEC-074。
|
|
13
13
|
- checkout 与 setup-vp Action 固定到已核对的 commit SHA;不通过 `pull_request_target` 运行不可信 PR。
|
|
14
14
|
- 验证任务只有 `contents: read`;只有发布 job 有 `id-token: write`。发布 step 只使用 OIDC(trusted publishing),不再读取任何 npm token secret。代码 checkout 不保留 Git 凭据。此工作流不会自动修改版本、提交源码或推送 Git。
|
|
15
15
|
|
|
@@ -58,7 +58,7 @@ Windows 全量测试最近仍有 3 个既有 POSIX 文件权限断言失败;
|
|
|
58
58
|
授权及完整验证均满足后:
|
|
59
59
|
|
|
60
60
|
```powershell
|
|
61
|
-
npm publish ./release-artifacts/leonhardthust-dsh-decision-engine-1.
|
|
61
|
+
npm publish ./release-artifacts/leonhardthust-dsh-decision-engine-1.14.0.tgz --registry "https://registry.npmjs.org/" --access public --tag latest
|
|
62
62
|
npm view @leonhardthust/dsh-decision-engine version --registry "https://registry.npmjs.org/" --json
|
|
63
63
|
```
|
|
64
64
|
|
|
@@ -66,4 +66,4 @@ npm view @leonhardthust/dsh-decision-engine version --registry "https://registry
|
|
|
66
66
|
|
|
67
67
|
## 安装
|
|
68
68
|
|
|
69
|
-
DSH 插件管理中使用 `@leonhardthust/dsh-decision-engine` 或指定 `@leonhardthust/dsh-decision-engine@1.
|
|
69
|
+
DSH 插件管理中使用 `@leonhardthust/dsh-decision-engine` 或指定 `@leonhardthust/dsh-decision-engine@1.14.0`。安装和启用是另外的用户动作,发布工作流不会修改本机 DSH profile。
|
package/lib/client.js
CHANGED
|
@@ -146,7 +146,7 @@ window.__ModuleLoader__.load({
|
|
|
146
146
|
// —— 匿名使用统计(DEC-020)——
|
|
147
147
|
"匿名使用统计": "Anonymous usage stats",
|
|
148
148
|
"每天上报一次:匿名安装 id、版本、平台与当日决策数": "Reports once a day: anonymous install id, version, platform, and today's decision count",
|
|
149
|
-
"每天一次,只用于让作者判断插件还有多少人在用、值不值得继续维护:随机安装 ID
|
|
149
|
+
"每天一次,只用于让作者判断插件还有多少人在用、值不值得继续维护:随机安装 ID、插件版本、平台/架构、宿主版本、上一完整自然日的决策次数。不含会话内容、文件路径、密钥、模型名或身份信息。": "Once a day, used only so the author can tell how many people still use the plugin and whether it is worth maintaining: a random install ID, plugin version, platform/arch, host version, and the previous complete day's decision count. No conversation content, file paths, keys, model names, or identity information.",
|
|
150
150
|
"已关闭:任何情况下都不联网": "Off: never touches the network",
|
|
151
151
|
"完全关闭:不发送任何请求。": "Fully off: no request is ever sent.",
|
|
152
152
|
"仅首次运行上报一次「安装了」,之后不再联网": "Reports \"installed\" once on first run, then never again",
|
|
@@ -282,6 +282,11 @@ window.__ModuleLoader__.load({
|
|
|
282
282
|
"节省比例(观察)": "Savings ratio (observe)",
|
|
283
283
|
// —— 页面标题与说明 ——
|
|
284
284
|
"用量统计与对比": "Usage & comparison",
|
|
285
|
+
"估算节省率": "Estimated savings rate",
|
|
286
|
+
"尝试 / 回答 / 降级": "Attempts / answered / degraded",
|
|
287
|
+
"决策请求估算节省率(观察)": "Estimated decision-request savings rate (observe)",
|
|
288
|
+
"口径:决策请求层面的估算(缓存去重 + 输入处理),不含主 Agent、子代理、工具调用与上下文压缩的成本变化;完整 Agent 净节省需要同任务配对 A/B,本页不提供。": "Scope: an estimate at the decision-request layer (cache dedupe + input processing). It excludes cost changes in the main agent, subagents, tool calls and context compaction; full agent net savings require a paired A/B and are not shown here.",
|
|
289
|
+
"安全口径:拦截率只按 authorize 点统计(本区间拒绝 {n} 次);其它点的 reject {m} 次属于流程优化,未计入。": "Safety scope: the block rate counts only the authorize point ({n} rejections here). The {m} rejections from other points are workflow optimizations and are not counted.",
|
|
285
290
|
"重新读取": "Reload",
|
|
286
291
|
"按 provider × model 独立统计;区间为左闭右开,相邻范围不会重复计入。": "Counted separately per provider × model; ranges are half-open, so adjacent ranges never double-count.",
|
|
287
292
|
"节省量只统计「观察」模式。": "Savings count only \"observe\" mode.",
|
|
@@ -5506,7 +5511,7 @@ window.__ModuleLoader__.load({
|
|
|
5506
5511
|
{ key: "savedOutputTokens", label: "省 output" },
|
|
5507
5512
|
{ key: "observeDurationMs", label: "实际耗时(观察)", actual: true },
|
|
5508
5513
|
{ key: "savedDurationMs", label: "省耗时" },
|
|
5509
|
-
{ key: "savedRatio", label: "
|
|
5514
|
+
{ key: "savedRatio", label: "估算节省率" },
|
|
5510
5515
|
];
|
|
5511
5516
|
|
|
5512
5517
|
/**
|
|
@@ -5529,10 +5534,19 @@ window.__ModuleLoader__.load({
|
|
|
5529
5534
|
totals === null
|
|
5530
5535
|
? []
|
|
5531
5536
|
: [
|
|
5532
|
-
{ label: "
|
|
5537
|
+
{ label: "尝试 / 回答 / 降级", value: `${groupDigits(totals.attempts ?? totals.requests)} / ${groupDigits(totals.requests)} / ${groupDigits(totals.degraded)}` },
|
|
5533
5538
|
{ label: "命中缓存", value: groupDigits(totals.cached) },
|
|
5534
5539
|
{ label: "input token", value: groupDigits(totals.inputTokens) },
|
|
5535
|
-
{
|
|
5540
|
+
{
|
|
5541
|
+
label: "output token",
|
|
5542
|
+
// 未知值不进入总量(详见 lib/stats.js):这里必须把未计量条数
|
|
5543
|
+
// 说出来,否则读者会把「已知部分」当成全部。
|
|
5544
|
+
value:
|
|
5545
|
+
groupDigits(totals.outputTokens) +
|
|
5546
|
+
((totals.outputTokensUnmeasured ?? 0) > 0
|
|
5547
|
+
? `(${groupDigits(totals.outputTokensUnmeasured)} 条未计量)`
|
|
5548
|
+
: ""),
|
|
5549
|
+
},
|
|
5536
5550
|
{ label: "总耗时", value: formatMs(totals.durationMs) },
|
|
5537
5551
|
// 写清「真实请求」:缓存命中的耗时是 0,混进分母会让这个
|
|
5538
5552
|
// 数字随命中率变好看,读的人以为请求变快了。
|
|
@@ -5552,7 +5566,7 @@ window.__ModuleLoader__.load({
|
|
|
5552
5566
|
saved: true,
|
|
5553
5567
|
},
|
|
5554
5568
|
{ label: "省时间(观察)", value: formatMs(totals.savedDurationMs), saved: true },
|
|
5555
|
-
{ label: "
|
|
5569
|
+
{ label: "决策请求估算节省率(观察)", value: formatRatio(totals.savedRatio), saved: true },
|
|
5556
5570
|
];
|
|
5557
5571
|
|
|
5558
5572
|
return h(
|
|
@@ -5610,6 +5624,20 @@ window.__ModuleLoader__.load({
|
|
|
5610
5624
|
h("strong", null, "节省量只统计「观察」模式。"),
|
|
5611
5625
|
" 观察模式下决策模型只记录、不改变行为,于是同一批工作既有普通 LLM 的实际消耗,也有决策模型「本该怎么跑」的结果——两者比的是同一批活,这个对比才成立。「生效」模式下普通 LLM 路径并没有真的发生,「省了多少」只能是事后估算,因此不计入。",
|
|
5612
5626
|
),
|
|
5627
|
+
// 口径边界(审计 P1):这是「决策请求层面」的估算,不是 Agent
|
|
5628
|
+
// 端到端净节省。不写清楚,读者会把 80% 当成「整个任务省了 80%」。
|
|
5629
|
+
h(
|
|
5630
|
+
"div",
|
|
5631
|
+
{ style: { marginTop: "4px" } },
|
|
5632
|
+
"口径:决策请求层面的估算(缓存去重 + 输入处理),不含主 Agent、子代理、工具调用与上下文压缩的成本变化;完整 Agent 净节省需要同任务配对 A/B,本页不提供。",
|
|
5633
|
+
),
|
|
5634
|
+
Number(stats?.estimatedBaselineMissing ?? 0) > 0
|
|
5635
|
+
? h(
|
|
5636
|
+
"div",
|
|
5637
|
+
{ style: { marginTop: "4px" } },
|
|
5638
|
+
`另有 ${groupDigits(stats.estimatedBaselineMissing)} 条历史缓存命中缺少基线,未计入节省(不再用同区间均值插补,避免时间泄漏与结果不可加)。`,
|
|
5639
|
+
)
|
|
5640
|
+
: null,
|
|
5613
5641
|
savingsUnavailable
|
|
5614
5642
|
? h(
|
|
5615
5643
|
"div",
|
|
@@ -6273,6 +6301,16 @@ window.__ModuleLoader__.load({
|
|
|
6273
6301
|
{ style: { marginTop: "6px" } },
|
|
6274
6302
|
"基线 = 不启用本插件的缓存与上下文压缩时,同样的工作本应消耗的 token 与时间。节省量相对基线计算(比例的分母是基线);没有可归因的优化时节省量为 0。平均耗时按**真实请求**计算,不含缓存命中(那一次根本没发请求)。思考模式(think_mode)是用户配置而非插件优化,刻意不计入节省。",
|
|
6275
6303
|
),
|
|
6304
|
+
stats.safety !== undefined
|
|
6305
|
+
? h(
|
|
6306
|
+
"div",
|
|
6307
|
+
{ style: { marginTop: "6px" } },
|
|
6308
|
+
t("安全口径:拦截率只按 authorize 点统计(本区间拒绝 {n} 次);其它点的 reject {m} 次属于流程优化,未计入。", {
|
|
6309
|
+
n: groupDigits(stats.safety.reject ?? 0),
|
|
6310
|
+
m: groupDigits(stats.safety.otherPointRejects ?? 0),
|
|
6311
|
+
}),
|
|
6312
|
+
)
|
|
6313
|
+
: null,
|
|
6276
6314
|
/**
|
|
6277
6315
|
* 模式分账:让用户一眼看出「节省为 0」的原因。
|
|
6278
6316
|
*
|
|
@@ -8779,7 +8817,7 @@ window.__ModuleLoader__.load({
|
|
|
8779
8817
|
// 与下拉的兜底保持一致:undefined 时下拉显示 daily,说明也必须说 daily,
|
|
8780
8818
|
// 否则首帧会出现「选中的是 daily、解释却写仅首次」的自相矛盾。
|
|
8781
8819
|
(draft.telemetryLevel ?? "daily") === "daily"
|
|
8782
|
-
? t("每天一次,只用于让作者判断插件还有多少人在用、值不值得继续维护:随机安装 ID
|
|
8820
|
+
? t("每天一次,只用于让作者判断插件还有多少人在用、值不值得继续维护:随机安装 ID、插件版本、平台/架构、宿主版本、上一完整自然日的决策次数。不含会话内容、文件路径、密钥、模型名或身份信息。")
|
|
8783
8821
|
: draft.telemetryLevel === "off"
|
|
8784
8822
|
? t("完全关闭:不发送任何请求。")
|
|
8785
8823
|
: t("仅在第一次运行时上报一次「安装了」(字段同上),之后不再联网。"),
|
package/lib/index.js
CHANGED
|
@@ -2221,9 +2221,11 @@ class StartluxClient {
|
|
|
2221
2221
|
const apiInputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : undefined;
|
|
2222
2222
|
const apiOutputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : undefined;
|
|
2223
2223
|
const inputSource = apiInputTokens === undefined ? "estimated" : "api";
|
|
2224
|
-
|
|
2224
|
+
// output token 没有估算器:缺了就是**未知**,不能写成 0 还标 estimated。
|
|
2225
|
+
// 写成 0 会把「不知道」伪装成「测得 0」,总量被系统性低估(审计 P0-1)。
|
|
2226
|
+
const outputSource = apiOutputTokens === undefined ? "missing" : "api";
|
|
2225
2227
|
const inputTokens = apiInputTokens ?? estimateRequestTokens(bodyText);
|
|
2226
|
-
const outputTokens = apiOutputTokens ??
|
|
2228
|
+
const outputTokens = apiOutputTokens ?? null;
|
|
2227
2229
|
const result = {
|
|
2228
2230
|
answers,
|
|
2229
2231
|
model: typeof body.model === "string" ? body.model : model || "(server-default)",
|
|
@@ -2242,10 +2244,11 @@ class StartluxClient {
|
|
|
2242
2244
|
outputTokens: apiOutputTokens,
|
|
2243
2245
|
inputSource,
|
|
2244
2246
|
outputSource,
|
|
2245
|
-
// 统计用的 token
|
|
2246
|
-
//
|
|
2247
|
+
// 统计用的 token 数(含估算)。input 有估算器;output 没有——缺了
|
|
2248
|
+
// 就是 null + missing,聚合时不进入总量,只计「未计量条数」。
|
|
2247
2249
|
statInputTokens: inputTokens,
|
|
2248
2250
|
statOutputTokens: outputTokens,
|
|
2251
|
+
outputTokensMissing: apiOutputTokens === undefined,
|
|
2249
2252
|
cached: false,
|
|
2250
2253
|
};
|
|
2251
2254
|
|
|
@@ -2280,6 +2283,7 @@ class StartluxClient {
|
|
|
2280
2283
|
mode: call.pointMode ?? "unknown",
|
|
2281
2284
|
inputTokens,
|
|
2282
2285
|
outputTokens,
|
|
2286
|
+
outputTokensMissing: apiOutputTokens === undefined,
|
|
2283
2287
|
durationMs: result.latencyMs,
|
|
2284
2288
|
inputSource,
|
|
2285
2289
|
outputSource,
|
|
@@ -2600,12 +2604,27 @@ export function apply(ctx, config) {
|
|
|
2600
2604
|
const level = cfg.telemetryLevel ?? DEFAULTS.telemetryLevel;
|
|
2601
2605
|
// off 时连读取都省掉:这是「关掉就是真的不联网」的执行点之一。
|
|
2602
2606
|
if (level === "off") return;
|
|
2607
|
+
/**
|
|
2608
|
+
* 统计**上一完整自然日**,而不是「今天到目前为止」。
|
|
2609
|
+
*
|
|
2610
|
+
* 审计 P0-3:旧实现取启动时的「今天」计数,上午启动时只看到当天前几次
|
|
2611
|
+
* 决策,随后全天几百次都不会再更新;服务端按收到日期归档,于是这一天
|
|
2612
|
+
* 的 decisions 被系统性低估。改为日终口径:本次上报的是昨天 00:00 到
|
|
2613
|
+
* 今天 00:00 的完整计数,并用 `day` 明确告诉服务端它属于哪一天。
|
|
2614
|
+
*/
|
|
2615
|
+
const now = new Date();
|
|
2616
|
+
const end = new Date(now);
|
|
2617
|
+
end.setHours(0, 0, 0, 0);
|
|
2618
|
+
const start = new Date(end);
|
|
2619
|
+
start.setDate(start.getDate() - 1);
|
|
2620
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
2621
|
+
const localStamp = (date) =>
|
|
2622
|
+
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
2623
|
+
const day = `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}`;
|
|
2603
2624
|
let decisions = 0;
|
|
2604
2625
|
try {
|
|
2605
|
-
// 只统计「今天」的决策数:心跳的语义是「今天用没用、用多少」。
|
|
2606
|
-
// 聚合失败就当 0(不上报该字段),不影响上报本身。
|
|
2607
2626
|
const rows = await statsStore.read();
|
|
2608
|
-
const range = resolveRange({ preset: "
|
|
2627
|
+
const range = resolveRange({ preset: "custom", start: localStamp(start), end: localStamp(end) });
|
|
2609
2628
|
if (range.valid === true) {
|
|
2610
2629
|
const result = aggregate(rows, range);
|
|
2611
2630
|
// 字段名是 `requests`(见 stats.js 的 emptyBucket)。这里曾写成
|
|
@@ -2629,6 +2648,7 @@ export function apply(ctx, config) {
|
|
|
2629
2648
|
// 宿主版本拿不到就留空,buildPayload 会整字段省略(不猜、不伪造)。
|
|
2630
2649
|
hostVersion: "",
|
|
2631
2650
|
decisions,
|
|
2651
|
+
day,
|
|
2632
2652
|
});
|
|
2633
2653
|
};
|
|
2634
2654
|
// 不 await:首次运行会真的发一次网络请求,绝不能拖住插件启动。
|
|
@@ -4178,6 +4198,16 @@ export function apply(ctx, config) {
|
|
|
4178
4198
|
.catch((err) => {
|
|
4179
4199
|
ctx.logger?.warn?.(`dsh-decision-engine: 匿名统计跳过(不影响插件):${String(err?.message ?? err)}`);
|
|
4180
4200
|
});
|
|
4201
|
+
// 跨日补报(审计 P0-3):长驻进程不会重新启动,只在启动时上报就永远
|
|
4202
|
+
// 只有第一天的心跳。每 6 小时检查一次;`decideKind` 用「今天是否已上报」
|
|
4203
|
+
// 去重,所以同一天不会重复发,跨日后会补报刚结束的那一天。
|
|
4204
|
+
const telemetryTimer = setInterval(() => {
|
|
4205
|
+
void reloadOwnConfig()
|
|
4206
|
+
.catch(() => {})
|
|
4207
|
+
.then(() => startTelemetry())
|
|
4208
|
+
.catch(() => {});
|
|
4209
|
+
}, 6 * 60 * 60 * 1000);
|
|
4210
|
+
telemetryTimer.unref?.();
|
|
4181
4211
|
|
|
4182
4212
|
}
|
|
4183
4213
|
|
package/lib/stats.js
CHANGED
|
@@ -74,6 +74,14 @@ export const TOKEN_SOURCES = {
|
|
|
74
74
|
estimated: "estimated",
|
|
75
75
|
/** 拿不到任何值(0)。 */
|
|
76
76
|
unknown: "unknown",
|
|
77
|
+
/**
|
|
78
|
+
* 服务端明确没有返回这个计量值。
|
|
79
|
+
*
|
|
80
|
+
* 与 `unknown` 的区别:`unknown` 是「老记录没带来源字段」,`missing` 是
|
|
81
|
+
* 「这次请求确实没有 output token 可用」。两者都不能当成 0 参与总量,
|
|
82
|
+
* 但 `missing` 需要单独计数以便报告覆盖率。
|
|
83
|
+
*/
|
|
84
|
+
missing: "missing",
|
|
77
85
|
};
|
|
78
86
|
|
|
79
87
|
/**
|
|
@@ -149,7 +157,8 @@ export function buildRecord(options = {}) {
|
|
|
149
157
|
const num = (value) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
|
|
150
158
|
const source = (value) =>
|
|
151
159
|
TOKEN_SOURCES[value] === TOKEN_SOURCES.api ||
|
|
152
|
-
TOKEN_SOURCES[value] === TOKEN_SOURCES.estimated
|
|
160
|
+
TOKEN_SOURCES[value] === TOKEN_SOURCES.estimated ||
|
|
161
|
+
TOKEN_SOURCES[value] === TOKEN_SOURCES.missing
|
|
153
162
|
? value
|
|
154
163
|
: TOKEN_SOURCES.unknown;
|
|
155
164
|
const inputTokens = num(options.inputTokens);
|
|
@@ -187,6 +196,15 @@ export function buildRecord(options = {}) {
|
|
|
187
196
|
baseDurationMs,
|
|
188
197
|
inputSource: source(options.inputSource),
|
|
189
198
|
outputSource: source(options.outputSource),
|
|
199
|
+
/**
|
|
200
|
+
* output token 未知。
|
|
201
|
+
*
|
|
202
|
+
* 此前 `outputTokens` 缺失时被写成 0、来源却标 estimated——等于把一个
|
|
203
|
+
* 「不知道」伪装成「测得 0」,总量被系统性低估。现在显式带一个标记,
|
|
204
|
+
* 聚合时**不把未知值加进已知总量**,只累计未计量条数,界面再报覆盖率。
|
|
205
|
+
*/
|
|
206
|
+
outputTokensMissing:
|
|
207
|
+
options.outputTokensMissing === true || options.outputSource === "missing",
|
|
190
208
|
};
|
|
191
209
|
}
|
|
192
210
|
|
|
@@ -414,6 +432,10 @@ function normalizeRecord(raw) {
|
|
|
414
432
|
baseDurationMs: num(raw?.baseDurationMs),
|
|
415
433
|
inputSource: typeof raw?.inputSource === "string" ? raw.inputSource : "unknown",
|
|
416
434
|
outputSource: typeof raw?.outputSource === "string" ? raw.outputSource : "unknown",
|
|
435
|
+
outputTokensMissing:
|
|
436
|
+
raw?.outputTokensMissing === true || raw?.outputSource === "missing",
|
|
437
|
+
/** 缓存命中的基线是否来自历史插补(审计 P1:不再插补,只标记)。 */
|
|
438
|
+
baselineEstimated: raw?.baselineEstimated === true,
|
|
417
439
|
// 预拼的「provider\0model」组合键:aggregate 过去每条记录拼一次、
|
|
418
440
|
// 基线补齐再拼一次,两次结果必然相同。放在归一化时算一次即可。
|
|
419
441
|
// 前缀 `\u0000` 保证它不会与被当作记录字段读取的键撞名。
|
|
@@ -454,6 +476,10 @@ export function isInRange(record, range) {
|
|
|
454
476
|
* @param record - 已规范化的记录。
|
|
455
477
|
*/
|
|
456
478
|
function accumulate(bucket, record) {
|
|
479
|
+
// 「尝试」= 发出去(或准备发出去)的每一次,含降级;「请求」= 拿到回答的
|
|
480
|
+
// 那部分。两者分开后,趋势图与汇总表可以用同一个分母对账:
|
|
481
|
+
// attempts = requests + degraded
|
|
482
|
+
bucket.attempts += 1;
|
|
457
483
|
if (record.degraded === true) {
|
|
458
484
|
bucket.degraded += 1;
|
|
459
485
|
bucket.degradedDurationMs += record.durationMs;
|
|
@@ -462,7 +488,12 @@ function accumulate(bucket, record) {
|
|
|
462
488
|
bucket.requests += 1;
|
|
463
489
|
if (record.cached === true) bucket.cached += 1;
|
|
464
490
|
bucket.inputTokens += record.inputTokens;
|
|
465
|
-
|
|
491
|
+
if (record.outputTokensMissing === true) {
|
|
492
|
+
// 未知值不进入已知总量,只计数;界面据此报「N 条未计量」。
|
|
493
|
+
bucket.outputTokensUnmeasured += 1;
|
|
494
|
+
} else {
|
|
495
|
+
bucket.outputTokens += record.outputTokens;
|
|
496
|
+
}
|
|
466
497
|
bucket.durationMs += record.durationMs;
|
|
467
498
|
/**
|
|
468
499
|
* 节省量只认**观察模式**的记录。
|
|
@@ -483,10 +514,13 @@ function accumulate(bucket, record) {
|
|
|
483
514
|
if (!contributesSavings(record.mode)) return;
|
|
484
515
|
bucket.observeRequests += 1;
|
|
485
516
|
bucket.observeInputTokens += record.inputTokens;
|
|
486
|
-
|
|
517
|
+
// output 未知时基线也无从复核:只跳过 output 这一对,不把 0 当实测。
|
|
518
|
+
if (record.outputTokensMissing !== true) {
|
|
519
|
+
bucket.observeOutputTokens += record.outputTokens;
|
|
520
|
+
bucket.observeBaseOutputTokens += record.baseOutputTokens;
|
|
521
|
+
}
|
|
487
522
|
bucket.observeDurationMs += record.durationMs;
|
|
488
523
|
bucket.observeBaseInputTokens += record.baseInputTokens;
|
|
489
|
-
bucket.observeBaseOutputTokens += record.baseOutputTokens;
|
|
490
524
|
bucket.observeBaseDurationMs += record.baseDurationMs;
|
|
491
525
|
}
|
|
492
526
|
|
|
@@ -510,6 +544,8 @@ function resetBucket(bucket) {
|
|
|
510
544
|
*/
|
|
511
545
|
function emptyBucket() {
|
|
512
546
|
return {
|
|
547
|
+
/** 尝试次数 = requests + degraded;对账用。 */
|
|
548
|
+
attempts: 0,
|
|
513
549
|
requests: 0,
|
|
514
550
|
cached: 0,
|
|
515
551
|
degraded: 0,
|
|
@@ -517,7 +553,11 @@ function emptyBucket() {
|
|
|
517
553
|
degradedDurationMs: 0,
|
|
518
554
|
inputTokens: 0,
|
|
519
555
|
outputTokens: 0,
|
|
556
|
+
/** output token 未知的记录数(不进入 outputTokens,见 accumulate)。 */
|
|
557
|
+
outputTokensUnmeasured: 0,
|
|
520
558
|
durationMs: 0,
|
|
559
|
+
/** 缓存命中却缺基线、无法估计节省的记录数(不插补,只计数)。 */
|
|
560
|
+
estimatedBaselineMissing: 0,
|
|
521
561
|
/**
|
|
522
562
|
* 观察模式专属的累加桶。
|
|
523
563
|
*
|
|
@@ -602,7 +642,10 @@ function finalizeBucket(bucket) {
|
|
|
602
642
|
* 这段过程,所以它不该出现在分母里。
|
|
603
643
|
*/
|
|
604
644
|
const realRequests = Math.max(0, bucket.requests - bucket.cached);
|
|
645
|
+
/** 有 output 计量的真实请求:平均 output 的分母不能把未知值当 0。 */
|
|
646
|
+
const measuredRequests = Math.max(0, realRequests - bucket.outputTokensUnmeasured);
|
|
605
647
|
return {
|
|
648
|
+
attempts: bucket.attempts,
|
|
606
649
|
requests: bucket.requests,
|
|
607
650
|
realRequests,
|
|
608
651
|
cached: bucket.cached,
|
|
@@ -611,6 +654,12 @@ function finalizeBucket(bucket) {
|
|
|
611
654
|
degradedDurationMs: bucket.degradedDurationMs,
|
|
612
655
|
inputTokens: bucket.inputTokens,
|
|
613
656
|
outputTokens: bucket.outputTokens,
|
|
657
|
+
/** output token 未能计量的记录数;>0 时总量是「已知部分」。 */
|
|
658
|
+
outputTokensUnmeasured: bucket.outputTokensUnmeasured,
|
|
659
|
+
/** 有 output 计量的真实请求数(= realRequests - 未计量)。 */
|
|
660
|
+
measuredRequests,
|
|
661
|
+
/** 缓存命中的历史记录里缺基线、无法估计节省的条数。 */
|
|
662
|
+
estimatedBaselineMissing: bucket.estimatedBaselineMissing,
|
|
614
663
|
totalTokens: bucket.inputTokens + bucket.outputTokens,
|
|
615
664
|
durationMs: bucket.durationMs,
|
|
616
665
|
/**
|
|
@@ -635,7 +684,14 @@ function finalizeBucket(bucket) {
|
|
|
635
684
|
savedTimeRatio,
|
|
636
685
|
avgDurationMs: realRequests === 0 ? 0 : Math.round(bucket.durationMs / realRequests),
|
|
637
686
|
avgInputTokens: realRequests === 0 ? 0 : Math.round(bucket.inputTokens / realRequests),
|
|
638
|
-
avgOutputTokens:
|
|
687
|
+
avgOutputTokens: measuredRequests === 0 ? 0 : Math.round(bucket.outputTokens / measuredRequests),
|
|
688
|
+
/**
|
|
689
|
+
* 节省量的**口径**。`decision_request` = 只看决策请求这一层:
|
|
690
|
+
* 缓存去重 + 输入处理,不含主 Agent / 子代理 / 工具开销。
|
|
691
|
+
* 完整 Agent 净节省只能靠配对 A/B(尚未实现),界面文案必须按这个
|
|
692
|
+
* 口径解释,不能把它说成「总体省了多少」。
|
|
693
|
+
*/
|
|
694
|
+
savedRatioScope: "decision_request",
|
|
639
695
|
};
|
|
640
696
|
}
|
|
641
697
|
|
|
@@ -948,10 +1004,20 @@ export function buildSeries(records, range, now = new Date()) {
|
|
|
948
1004
|
buckets.forEach((key, index) => indexOf.set(key, index));
|
|
949
1005
|
|
|
950
1006
|
const series = {
|
|
1007
|
+
/** 尝试次数(含降级);与 totals.attempts 同口径。 */
|
|
1008
|
+
attempts: new Array(buckets.length).fill(0),
|
|
1009
|
+
/** 拿到回答的请求数(不含降级);与 totals.requests 同口径。 */
|
|
951
1010
|
requests: new Array(buckets.length).fill(0),
|
|
1011
|
+
/** 降级次数;与 totals.degraded 同口径。 */
|
|
1012
|
+
degraded: new Array(buckets.length).fill(0),
|
|
952
1013
|
inputTokens: new Array(buckets.length).fill(0),
|
|
953
1014
|
outputTokens: new Array(buckets.length).fill(0),
|
|
1015
|
+
/** 该桶里 output token 未知的记录数(不进入 outputTokens)。 */
|
|
1016
|
+
outputTokensUnmeasured: new Array(buckets.length).fill(0),
|
|
1017
|
+
/** 已回答请求的耗时(不含降级);与 totals.durationMs 同口径。 */
|
|
954
1018
|
durationMs: new Array(buckets.length).fill(0),
|
|
1019
|
+
/** 降级累计等待耗时;与 totals.degradedDurationMs 同口径。 */
|
|
1020
|
+
degradedDurationMs: new Array(buckets.length).fill(0),
|
|
955
1021
|
allow: new Array(buckets.length).fill(0),
|
|
956
1022
|
ask: new Array(buckets.length).fill(0),
|
|
957
1023
|
reject: new Array(buckets.length).fill(0),
|
|
@@ -964,11 +1030,21 @@ export function buildSeries(records, range, now = new Date()) {
|
|
|
964
1030
|
// 落在区间外的记录(理论上已被筛掉)直接跳过:宁少一个点,
|
|
965
1031
|
// 也不要把它的值塞进错误的桶里——那会让图与表对不上。
|
|
966
1032
|
if (index === undefined) continue;
|
|
1033
|
+
series.attempts[index] += 1;
|
|
1034
|
+
if (record.degraded === true) {
|
|
1035
|
+
// 与 accumulate 完全同口径:降级只进 attempts / degraded,
|
|
1036
|
+
// 不进 requests / token / 回答耗时,否则「分桶之和」又对不上汇总。
|
|
1037
|
+
series.degraded[index] += 1;
|
|
1038
|
+
series.degradedDurationMs[index] += record.durationMs;
|
|
1039
|
+
series.none[index] += 1;
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
967
1042
|
series.requests[index] += 1;
|
|
968
1043
|
series.inputTokens[index] += record.inputTokens;
|
|
969
|
-
series.
|
|
1044
|
+
if (record.outputTokensMissing === true) series.outputTokensUnmeasured[index] += 1;
|
|
1045
|
+
else series.outputTokens[index] += record.outputTokens;
|
|
970
1046
|
series.durationMs[index] += record.durationMs;
|
|
971
|
-
const hasVerdict = record.action.length > 0
|
|
1047
|
+
const hasVerdict = record.action.length > 0;
|
|
972
1048
|
const outcome = hasVerdict ? outcomeOf(record.point, record.action) : "none";
|
|
973
1049
|
series[outcome === "none" ? "none" : outcome][index] += 1;
|
|
974
1050
|
}
|
|
@@ -1115,59 +1191,27 @@ export function aggregate(records, range) {
|
|
|
1115
1191
|
* 而历史记录里缓存命中的占比往往不低(实测 telemetry.jsonl 里 678/2029
|
|
1116
1192
|
* 条是缓存命中),只补耗时会让节省量看起来偏小。
|
|
1117
1193
|
*/
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
* token 数),但**耗时**基线老遥测确实没有。若按「任一基线存在就跳过」
|
|
1140
|
-
* 判断,这批记录的耗时节省会永远是 0——而缓存命中省下的时间恰恰是
|
|
1141
|
-
* 用户最想看到的数字。
|
|
1142
|
-
*/
|
|
1143
|
-
if (record.baseInputTokens === 0) {
|
|
1144
|
-
record.baseInputTokens = Math.round(sample.input / sample.count);
|
|
1145
|
-
needsRecount = true;
|
|
1146
|
-
}
|
|
1147
|
-
if (record.baseOutputTokens === 0) {
|
|
1148
|
-
record.baseOutputTokens = Math.round(sample.output / sample.count);
|
|
1149
|
-
needsRecount = true;
|
|
1150
|
-
}
|
|
1151
|
-
if (record.baseDurationMs === 0) {
|
|
1152
|
-
record.baseDurationMs = Math.round(sample.duration / sample.count);
|
|
1153
|
-
needsRecount = true;
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
// 基线补齐后要重新累加一遍:上面的 accumulate 发生在补齐之前,
|
|
1158
|
-
// 补出来的基线不会自动进桶。重新累加的代价是一次 O(n) 遍历,
|
|
1159
|
-
// 与统计本身相比可忽略。
|
|
1160
|
-
if (needsRecount) {
|
|
1161
|
-
for (const bucket of [totals, ...byCombo.values(), ...byProvider.values(), ...byPoint.values()]) {
|
|
1162
|
-
resetBucket(bucket);
|
|
1163
|
-
}
|
|
1164
|
-
for (const record of scoped) {
|
|
1165
|
-
accumulate(totals, record);
|
|
1166
|
-
accumulate(byCombo.get(record.comboKey), record);
|
|
1167
|
-
accumulate(byProvider.get(record.provider), record);
|
|
1168
|
-
accumulate(byPoint.get(record.point), record);
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1194
|
+
/**
|
|
1195
|
+
* 历史缓存命中的基线缺失——**不再用查询区间的均值插补**。
|
|
1196
|
+
*
|
|
1197
|
+
* 均值插补有三个确认的问题:
|
|
1198
|
+
* 1. 同一模型同时处理很短与很长的请求,按模型分组的均值不保证
|
|
1199
|
+
* 与被插补请求同分布;
|
|
1200
|
+
* 2. 较早的命中可以借用同窗口内较晚发生的请求作基线(时间泄漏);
|
|
1201
|
+
* 3. 同一记录在不同查询窗口会得到不同基线,节省量不可加
|
|
1202
|
+
* (S(A∪B) ≠ S(A)+S(B))。
|
|
1203
|
+
*
|
|
1204
|
+
* 现在只计数并上报:节省量只用「写入时已持久化」的精确基线(新记录
|
|
1205
|
+
* 携带 baseInput/baseDuration),缺基线的历史命中不计入节省,但
|
|
1206
|
+
* `estimatedBaselineMissing` 让界面明确告诉用户有多少条无法估计。
|
|
1207
|
+
*/
|
|
1208
|
+
const estimatedBaselineMissing = scoped.filter(
|
|
1209
|
+
(record) =>
|
|
1210
|
+
record.cached === true
|
|
1211
|
+
&& record.baseInputTokens === 0
|
|
1212
|
+
&& record.baseOutputTokens === 0
|
|
1213
|
+
&& record.baseDurationMs === 0,
|
|
1214
|
+
).length;
|
|
1171
1215
|
|
|
1172
1216
|
const rows = [...byCombo.entries()].map(([comboKey, bucket]) => {
|
|
1173
1217
|
const [provider, model] = comboKey.split("\u0000");
|
|
@@ -1185,6 +1229,26 @@ export function aggregate(records, range) {
|
|
|
1185
1229
|
point,
|
|
1186
1230
|
...bucket,
|
|
1187
1231
|
}));
|
|
1232
|
+
/**
|
|
1233
|
+
* 安全口径(审计 P1):`authorize` 的 reject 才是「安全拒绝」。
|
|
1234
|
+
*
|
|
1235
|
+
* retrieval.skip / skillPick.redirect / route.switch / compact.prune 都是
|
|
1236
|
+
* 流程优化,它们也会产生 reject/allow,但与「有没有放行一次危险调用」
|
|
1237
|
+
* 不是同一件事。把所有点的 reject 合成一个「拦截率」,启用越多优化
|
|
1238
|
+
* 拦截率越高,而安全策略可能一点没变。这里单独给出 authorize 的分账,
|
|
1239
|
+
* 其它点的 reject 只计数、不进入安全拦截率。
|
|
1240
|
+
*/
|
|
1241
|
+
const authorizeOutcome = outcomeByPoint.get("authorize") ?? emptyOutcomeBucket();
|
|
1242
|
+
const safety = {
|
|
1243
|
+
point: "authorize",
|
|
1244
|
+
allow: authorizeOutcome.allow,
|
|
1245
|
+
ask: authorizeOutcome.ask,
|
|
1246
|
+
reject: authorizeOutcome.reject,
|
|
1247
|
+
/** 其它决策点产生的 reject(流程优化,不计入安全拦截率)。 */
|
|
1248
|
+
otherPointRejects: Math.max(0, outcomeTotals.reject - authorizeOutcome.reject),
|
|
1249
|
+
note:
|
|
1250
|
+
"安全拦截率只统计 authorize 点;retrieval / skillPick / route / compact 的 reject 属于流程优化,不计入。",
|
|
1251
|
+
};
|
|
1188
1252
|
/**
|
|
1189
1253
|
* 裁决总览:三格 + 未裁决 + 未分类明细。
|
|
1190
1254
|
*
|
|
@@ -1244,6 +1308,10 @@ export function aggregate(records, range) {
|
|
|
1244
1308
|
*/
|
|
1245
1309
|
series,
|
|
1246
1310
|
recordCount: scoped.length,
|
|
1311
|
+
/** 缺基线、无法估计节省的历史缓存命中条数(不插补,见上)。 */
|
|
1312
|
+
estimatedBaselineMissing,
|
|
1313
|
+
/** 安全分账:只有 authorize 点算安全拦截(见上)。 */
|
|
1314
|
+
safety,
|
|
1247
1315
|
/**
|
|
1248
1316
|
* 节省量的**来源说明**。
|
|
1249
1317
|
*
|
package/lib/telemetry.js
CHANGED
|
@@ -172,6 +172,17 @@ function shortField(value, max = 24) {
|
|
|
172
172
|
return String(value ?? "").trim().replace(/[^0-9A-Za-z._+-]/g, "").slice(0, max);
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* 规范化上报的日期(`YYYY-MM-DD`)。
|
|
177
|
+
*
|
|
178
|
+
* @param value - 原始值。
|
|
179
|
+
* @returns 合法日期串;非法返回空串(字段省略,服务端按收到时间归档)。
|
|
180
|
+
*/
|
|
181
|
+
export function normalizeDay(value) {
|
|
182
|
+
const text = String(value ?? "").trim();
|
|
183
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : "";
|
|
184
|
+
}
|
|
185
|
+
|
|
175
186
|
/**
|
|
176
187
|
* 插件标识(npm 包名的合法形态):小写,字母数字与 `-` / `.` / `_`,最长 48。
|
|
177
188
|
*
|
|
@@ -201,7 +212,8 @@ export function normalizePluginId(value) {
|
|
|
201
212
|
* @param input.platform - `process.platform`。
|
|
202
213
|
* @param input.arch - `process.arch`。
|
|
203
214
|
* @param input.hostVersion - 宿主版本(拿不到时为空串)。
|
|
204
|
-
* @param input.decisions -
|
|
215
|
+
* @param input.decisions - 上一完整自然日的决策数(0 表示没有,不上报该字段)。
|
|
216
|
+
* @param input.day - 这组计数属于哪一天(`YYYY-MM-DD`,可选);服务端据此归档。
|
|
205
217
|
* @returns 可直接 `JSON.stringify` 的对象。
|
|
206
218
|
*/
|
|
207
219
|
export function buildPayload(input = {}) {
|
|
@@ -218,6 +230,11 @@ export function buildPayload(input = {}) {
|
|
|
218
230
|
};
|
|
219
231
|
const host = normalizeVersion(input.hostVersion);
|
|
220
232
|
if (host.length > 0) payload.host = host;
|
|
233
|
+
// 客户端声明的「这组决策数属于哪一天」。日终/次日上报上一完整自然日时,
|
|
234
|
+
// 服务端必须按这个日期落库,而不是按收到请求的日期——否则昨天的计数会
|
|
235
|
+
// 被记到今天(审计 P0-3)。非法值不带,服务端退回收到时间。
|
|
236
|
+
const day = normalizeDay(input.day);
|
|
237
|
+
if (day.length > 0) payload.day = day;
|
|
221
238
|
const decisions = Number(input.decisions);
|
|
222
239
|
if (Number.isFinite(decisions) && decisions > 0) payload.d = Math.min(Math.trunc(decisions), 100000);
|
|
223
240
|
return payload;
|
|
@@ -386,6 +403,7 @@ export class TelemetryReporter {
|
|
|
386
403
|
arch: input.arch,
|
|
387
404
|
hostVersion: input.hostVersion,
|
|
388
405
|
decisions: input.decisions,
|
|
406
|
+
day: input.day,
|
|
389
407
|
});
|
|
390
408
|
return { state: next, payload, today, kind };
|
|
391
409
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leonhardthust/dsh-decision-engine",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"description": "dsh-decision-engine: a cheap judgement layer for DeepSeek Harness — a small decision model answers structured questions before the main model acts, so calls can be allowed, escalated, rerouted to a cheaper model, or skipped. All eight decision points (tool authorization, model routing, delegation, compaction, turn continuation, retrieval, skill pick, COT translation) are implemented and individually switchable. Works against any service exposing a decision endpoint: built-in presets (system one, bamboonode, SiliconFlow, Silicon Flow) or a custom base URL.",
|
|
5
5
|
"icon": "./icon.svg",
|
|
6
6
|
"type": "module",
|