@leonhardthust/dsh-decision-engine 0.0.0-stage → 1.9.1
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/LICENSE +21 -0
- package/README.md +55 -2
- package/README.zh.md +57 -0
- package/config.example.yml +436 -0
- package/cordis.patch.yml +14 -0
- package/docs/assets/config-page.png +0 -0
- package/docs/assets/decisions-tab.png +0 -0
- package/docs/assets/hero.jpeg +0 -0
- package/docs/assets/stats-charts-preview.png +0 -0
- package/docs/assets/stats-donut-toggle.png +0 -0
- package/docs/assets/stats-interactive-charts.png +0 -0
- package/docs/assets/stats-point-donut.png +0 -0
- package/docs/assets/stats-ranking-bars.png +0 -0
- package/docs/auto-review-hotfix.md +33 -0
- package/docs/lessons/cloudflare-builds-root-directory.md +51 -0
- package/docs/lessons/cold-start-timeout.md +134 -0
- package/docs/lessons/endpoint-path-vs-model.md +26 -0
- package/docs/lessons/model-field-required-by-provider.md +31 -0
- package/docs/local-dev-link.md +54 -0
- package/docs/releasing.md +69 -0
- package/docs/security-revision.md +130 -0
- package/icon.svg +6 -0
- package/lib/adapters/agent.js +314 -0
- package/lib/adapters/route.js +144 -0
- package/lib/adapters/tools.js +186 -0
- package/lib/adapters/translate.js +143 -0
- package/lib/approval-detail.js +325 -0
- package/lib/client.charts.js +36 -0
- package/lib/client.js +8715 -0
- package/lib/config-file.js +136 -0
- package/lib/credentials.js +187 -0
- package/lib/decision-cache.js +188 -0
- package/lib/decision-points.js +205 -0
- package/lib/decision-store.js +774 -0
- package/lib/endpoint-fallback.js +143 -0
- package/lib/engine.js +312 -0
- package/lib/guard/advisory.js +100 -0
- package/lib/guard/audit.js +81 -0
- package/lib/guard/context.js +128 -0
- package/lib/guard/effects.js +104 -0
- package/lib/guard/fallback.js +139 -0
- package/lib/guard/listener.js +214 -0
- package/lib/guard/messages.js +31 -0
- package/lib/guard/policy.js +28 -0
- package/lib/home-paths.js +86 -0
- package/lib/index.js +3823 -0
- package/lib/outcomes.js +169 -0
- package/lib/points/spec.js +645 -0
- package/lib/providers.js +958 -0
- package/lib/resilience.js +308 -0
- package/lib/scope.js +67 -0
- package/lib/server-config.js +351 -0
- package/lib/session-events.js +75 -0
- package/lib/stats-store.js +916 -0
- package/lib/stats.js +1293 -0
- package/lib/telemetry.js +465 -0
- package/lib/tokenizer.js +201 -0
- package/lib/tolerance.js +498 -0
- package/lib/vendor/microcharts-charts.css +47 -0
- package/lib/vendor-src/entry.js +13 -0
- package/lib/vendor-src/jsx-shim.js +34 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +101 -3
- package/scripts/migrate-profile.mjs +102 -0
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统计数据的**持久化**层。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么不用 telemetry.jsonl
|
|
5
|
+
*
|
|
6
|
+
* 需求要求按 provider / model 维度统计 token 与耗时,而现有
|
|
7
|
+
* `telemetry.jsonl` 的字段**覆盖不了**:
|
|
8
|
+
*
|
|
9
|
+
* - **没有 provider**:只有 model。同一模型在两个 provider 上跑,分不开。
|
|
10
|
+
* - **没有 output_tokens**:API 返回了 `usage.output_tokens`,但遥测只记
|
|
11
|
+
* 了 input。节省量必须同时看输入与输出。
|
|
12
|
+
* - **没有基线**:没有「不优化时会花多少」,只能事后猜。
|
|
13
|
+
* - **耗时口径不一致**:遥测里的 `latencyMs` 是缓存命中时的 0,
|
|
14
|
+
* 与真实请求的耗时混在同一个字段里,累加出来的「总耗时」没有意义。
|
|
15
|
+
*
|
|
16
|
+
* 所以统计走**独立的 `stats.jsonl`**,与遥测各管各的:
|
|
17
|
+
*
|
|
18
|
+
* - `telemetry.jsonl` 服务于「事后核对决策过程」(人读的流水账);
|
|
19
|
+
* - `stats.jsonl` 服务于「统计与对比」(机器读的结构化度量)。
|
|
20
|
+
*
|
|
21
|
+
* ## 为什么用 JSONL 而不是 SQLite
|
|
22
|
+
*
|
|
23
|
+
* 需求给的两条路都可行。选 JSONL 的理由:
|
|
24
|
+
*
|
|
25
|
+
* 1. **零依赖**:本插件的硬约束是零运行时依赖。`node:sqlite` 虽然是
|
|
26
|
+
* Node 内置的,但在 Electron 里需要 `--experimental-sqlite`(旧版)
|
|
27
|
+
* 或承担版本差异,而 JSONL 只需要 `appendFile`。
|
|
28
|
+
* 2. **写入路径简单**:每次一条 `appendFile`,天然原子、不需要事务、
|
|
29
|
+
* 不需要担心「写到一半进程被杀」。崩溃时最多丢最后一条。
|
|
30
|
+
* 3. **可读**:用户能直接 `tail -f stats.jsonl` 看,比开 sqlite3 方便。
|
|
31
|
+
* 4. **统计量级不构成压力**:决策请求是分钟级频率,一天几千条,
|
|
32
|
+
* JSONL 完全撑得住。
|
|
33
|
+
*
|
|
34
|
+
* ## 写入为什么不影响主功能
|
|
35
|
+
*
|
|
36
|
+
* `record()` **不 await**:它把写盘动作排进一个串行队列就立刻返回。
|
|
37
|
+
* 决策路径上的等待时间因此恒为「入队 + 拼字符串」,而不是「等磁盘」。
|
|
38
|
+
* 队列内部保证顺序与不交错(否则并发写会串行),失败只记一次警告。
|
|
39
|
+
*
|
|
40
|
+
* @module dsh-decision-engine/stats-store
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { appendFile, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
44
|
+
import { dirname, join } from "node:path";
|
|
45
|
+
import { PROVIDER_PRESETS } from "./providers.js";
|
|
46
|
+
import { DECISION_POINTS } from "./decision-points.js";
|
|
47
|
+
|
|
48
|
+
/** 统计文件名(与 `telemetry.jsonl` 同目录)。 */
|
|
49
|
+
export const STATS_FILE_NAME = "stats.jsonl";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 老遥测的「模型名 → provider」反推表。
|
|
53
|
+
*
|
|
54
|
+
* 只用于**回填**:老遥测没有 provider 字段(上面「为什么不用 telemetry.jsonl」
|
|
55
|
+
* 一节第一条),但其 `model` 字段往往能反推出服务商——
|
|
56
|
+
*
|
|
57
|
+
* - `startlux-*` 是 bamboonode 的默认决策模型(老版本唯一端点就是 bamboonode,
|
|
58
|
+
* 没有 provider 概念时默认走它);
|
|
59
|
+
* - `Kev-*` / `SemIf*` 是硅基流动(siliconflow,国内站)的模型。
|
|
60
|
+
*
|
|
61
|
+
* 两个 SiliconFlow 站点(国内站 / 国际站)都提供 Kev-4B / SemIf,老遥测分不出
|
|
62
|
+
* 来,平局按用户选择归国内站(siliconflow)。
|
|
63
|
+
*
|
|
64
|
+
* 反推是**尽力而为**:匹配不上的(例如老遥测里 model 也是空,或某个第三方
|
|
65
|
+
* 模型名)退回 `"unknown"`,由界面显示为「未知(历史)」——绝不能为了消除
|
|
66
|
+
* 这个标签而去凭空猜一个服务商(那会让「按 provider 对比」出现一家不存在的)。
|
|
67
|
+
*/
|
|
68
|
+
const LEGACY_MODEL_PROVIDERS = [
|
|
69
|
+
{ brand: "startlux", provider: "bamboonode" },
|
|
70
|
+
{ brand: "kev", provider: "siliconflow" },
|
|
71
|
+
{ brand: "semif", provider: "siliconflow" },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 从模型名反推老记录的 provider。
|
|
76
|
+
*
|
|
77
|
+
* @param model - 老遥测里的模型名(可能为任意字符串或缺失)。
|
|
78
|
+
* @returns provider id;匹配不上返回 `"unknown"`。
|
|
79
|
+
*/
|
|
80
|
+
export function inferLegacyProvider(model) {
|
|
81
|
+
const name = typeof model === "string" ? model.trim().toLowerCase() : "";
|
|
82
|
+
if (name.length === 0) return "unknown";
|
|
83
|
+
for (const { brand, provider } of LEGACY_MODEL_PROVIDERS) {
|
|
84
|
+
if (name.startsWith(brand)) return provider;
|
|
85
|
+
}
|
|
86
|
+
return "unknown";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 从遥测条目里找回 provider(回填用)。
|
|
91
|
+
*
|
|
92
|
+
* 来源按可靠度排序:
|
|
93
|
+
*
|
|
94
|
+
* 1. `entry.provider` —— 新版遥测直接带(这是**根治点**:遥测从 2026-10-27
|
|
95
|
+
* 起每次决策都记 provider,回填不必再猜)。
|
|
96
|
+
* 2. `entry.extra.provider` —— 降级记录里服务商 id 已经被记进 `extra`。
|
|
97
|
+
* 3. `entry.extra.endpoint` 的主机名 —— 降级记录几乎都带 endpoint,按预设的
|
|
98
|
+
* baseUrl 主机名反查服务商(bamboonode / 硅基流动国内站 / 国际站 / system one)。
|
|
99
|
+
* 4. 按模型名反推(`inferLegacyProvider`)—— 老遥测最后的手段。
|
|
100
|
+
*
|
|
101
|
+
* @param entry - 遥测条目。
|
|
102
|
+
* @param model - 条目里的模型名(可能为空)。
|
|
103
|
+
* @returns provider id;实在找不出返回 `"unknown"`。
|
|
104
|
+
*/
|
|
105
|
+
export function providerOfTelemetryEntry(entry, model) {
|
|
106
|
+
const direct = typeof entry?.provider === "string" ? entry.provider.trim() : "";
|
|
107
|
+
if (direct.length > 0 && direct !== "unknown") return direct;
|
|
108
|
+
const extraProvider = typeof entry?.extra?.provider === "string" ? entry.extra.provider.trim() : "";
|
|
109
|
+
if (extraProvider.length > 0 && extraProvider !== "unknown") return extraProvider;
|
|
110
|
+
const endpoint = typeof entry?.extra?.endpoint === "string" ? entry.extra.endpoint : "";
|
|
111
|
+
const byEndpoint = providerByEndpoint(endpoint);
|
|
112
|
+
if (byEndpoint !== "unknown") return byEndpoint;
|
|
113
|
+
return inferLegacyProvider(model);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 按 endpoint 的主机名反查服务商。
|
|
118
|
+
*
|
|
119
|
+
* 只认**内置预设**的 baseUrl 主机名:自建/自定义地址(custom)无从判断归属,
|
|
120
|
+
* 宁可留 unknown,也不猜。
|
|
121
|
+
*
|
|
122
|
+
* @param endpoint - 请求地址(可能为空或非法)。
|
|
123
|
+
* @returns provider id;匹配不上返回 `"unknown"`。
|
|
124
|
+
*/
|
|
125
|
+
export function providerByEndpoint(endpoint) {
|
|
126
|
+
const raw = typeof endpoint === "string" ? endpoint.trim() : "";
|
|
127
|
+
if (raw.length === 0) return "unknown";
|
|
128
|
+
let host;
|
|
129
|
+
try {
|
|
130
|
+
host = new URL(raw).host.toLowerCase();
|
|
131
|
+
} catch {
|
|
132
|
+
return "unknown";
|
|
133
|
+
}
|
|
134
|
+
if (host.length === 0) return "unknown";
|
|
135
|
+
for (const preset of Object.values(PROVIDER_PRESETS)) {
|
|
136
|
+
const base = typeof preset.baseUrl === "string" ? preset.baseUrl : "";
|
|
137
|
+
if (base.length === 0) continue;
|
|
138
|
+
try {
|
|
139
|
+
if (new URL(base).host.toLowerCase() === host) return preset.id;
|
|
140
|
+
} catch {
|
|
141
|
+
/* 预设地址不合法就跳过 */
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return "unknown";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 取某个 provider 的默认模型(回填与迁移用)。
|
|
149
|
+
*
|
|
150
|
+
* 降级记录没有拿到任何答案,本就没有 model;但服务商是知道的,而服务商
|
|
151
|
+
* 有默认模型——那就是「本来会用的那个」。落到它上面,降级记录才会并进
|
|
152
|
+
* 正确的那一行,而不是自成一行「某 provider / unknown」零数据。
|
|
153
|
+
*
|
|
154
|
+
* @param provider - provider id。
|
|
155
|
+
* @returns 默认模型名;该 provider 没有默认模型时返回 `"unknown"`。
|
|
156
|
+
*/
|
|
157
|
+
export function defaultModelOf(provider) {
|
|
158
|
+
const id = typeof provider === "string" ? provider.trim() : "";
|
|
159
|
+
if (id.length === 0 || id === "unknown") return "unknown";
|
|
160
|
+
const preset = PROVIDER_PRESETS[id];
|
|
161
|
+
const model = typeof preset?.defaultModel === "string" ? preset.defaultModel.trim() : "";
|
|
162
|
+
return model.length > 0 ? model : "unknown";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 从老遥测的 tag 反推决策点。
|
|
167
|
+
*
|
|
168
|
+
* @param tag - 老遥测条目的 tag(可能缺失)。
|
|
169
|
+
* @param point - 条目自带的 point 字段(新遥测有;有就直接用)。
|
|
170
|
+
* @returns 决策点 id;无法确定返回 `"unknown"`。
|
|
171
|
+
*/
|
|
172
|
+
export function inferLegacyPoint(tag, point) {
|
|
173
|
+
if (typeof point === "string" && point.trim().length > 0) return point.trim();
|
|
174
|
+
const value = typeof tag === "string" ? tag.trim() : "";
|
|
175
|
+
if (value.length === 0) return "unknown";
|
|
176
|
+
if (value === "guard") return "authorize";
|
|
177
|
+
if (value.startsWith("adapter/")) return value.slice("adapter/".length);
|
|
178
|
+
// 引擎写的 tag 就是决策点 id(engine.js: tag: id)。必须与注册表核对:
|
|
179
|
+
// 遥测里若出现未登记的 tag,不能想当然当成一个决策点。
|
|
180
|
+
for (const def of DECISION_POINTS) {
|
|
181
|
+
if (def.id === value) return value;
|
|
182
|
+
}
|
|
183
|
+
return "unknown";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 统计存储。
|
|
188
|
+
*
|
|
189
|
+
* 用法:
|
|
190
|
+
*
|
|
191
|
+
* const store = new StatsStore({ dir });
|
|
192
|
+
* store.record(buildRecord({ … })); // 不 await,异步落盘
|
|
193
|
+
* const records = await store.read(); // 读全部
|
|
194
|
+
*/
|
|
195
|
+
export class StatsStore {
|
|
196
|
+
/**
|
|
197
|
+
* @param options.dir - 插件数据目录(`~/.dsh/dsh-decision-engine/`)。
|
|
198
|
+
* @param options.fileName - 文件名,默认 `stats.jsonl`。
|
|
199
|
+
* @param options.logger - 宿主 logger;写盘失败时用它警告。
|
|
200
|
+
* @param options.maxQueue - 待写队列上限;超过则丢弃最旧的条目。
|
|
201
|
+
* 防止磁盘挂掉时队列无限增长吃光内存。
|
|
202
|
+
*/
|
|
203
|
+
constructor(options = {}) {
|
|
204
|
+
this.dir = options.dir;
|
|
205
|
+
this.fileName = options.fileName ?? STATS_FILE_NAME;
|
|
206
|
+
this.logger = options.logger;
|
|
207
|
+
this.maxQueue = Number.isFinite(options.maxQueue) ? Math.max(16, options.maxQueue) : 4096;
|
|
208
|
+
/** 待写队列(字符串,已序列化)。 */
|
|
209
|
+
this.queue = [];
|
|
210
|
+
/** 正在进行的写盘任务。 */
|
|
211
|
+
this.pending = null;
|
|
212
|
+
/** 累计写入条数(诊断用)。 */
|
|
213
|
+
this.written = 0;
|
|
214
|
+
/** 累计失败条数(诊断用)。 */
|
|
215
|
+
this.failed = 0;
|
|
216
|
+
/** 队列被丢弃的条数(磁盘异常时的兜底计数)。 */
|
|
217
|
+
this.dropped = 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 统计文件的绝对路径。 */
|
|
221
|
+
get path() {
|
|
222
|
+
return join(this.dir, this.fileName);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 记录一条统计。
|
|
227
|
+
*
|
|
228
|
+
* **不返回 Promise**:调用方不该 await 它。写盘在后台串行进行,
|
|
229
|
+
* 决策路径上的额外开销只有一次数组 push 与一次 JSON.stringify。
|
|
230
|
+
*
|
|
231
|
+
* @param record - `buildRecord()` 造出的记录。
|
|
232
|
+
*/
|
|
233
|
+
record(record) {
|
|
234
|
+
if (record === null || typeof record !== "object") return;
|
|
235
|
+
let line;
|
|
236
|
+
try {
|
|
237
|
+
line = JSON.stringify(record);
|
|
238
|
+
} catch {
|
|
239
|
+
// 循环引用之类的问题不该把调用方带崩——统计是旁路。
|
|
240
|
+
this.failed += 1;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (this.queue.length >= this.maxQueue) {
|
|
244
|
+
// 队列满说明磁盘写不过来(或者进程被 SIGSTOP 了)。丢最旧的,
|
|
245
|
+
// 保最新的——统计的价值在近期数据。
|
|
246
|
+
this.queue.shift();
|
|
247
|
+
this.dropped += 1;
|
|
248
|
+
}
|
|
249
|
+
this.queue.push(`${line}\n`);
|
|
250
|
+
this.schedule();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 安排一次落盘。
|
|
255
|
+
*
|
|
256
|
+
* 串行保证顺序:写入中的那一次没结束就不再发起新的写,否则两个
|
|
257
|
+
* `appendFile` 并发会让同一行被拆成两半(JSONL 一行被拆开后,
|
|
258
|
+
* 解析时会被当成两条坏行丢掉)。
|
|
259
|
+
*/
|
|
260
|
+
schedule() {
|
|
261
|
+
if (this.pending !== null) return;
|
|
262
|
+
this.pending = this.drain().finally(() => {
|
|
263
|
+
this.pending = null;
|
|
264
|
+
// 入队期间又来了新条目:继续排空。
|
|
265
|
+
if (this.queue.length > 0) this.schedule();
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 把队列里的条目写进文件。
|
|
271
|
+
*
|
|
272
|
+
* 一次 `appendFile` 写一批,而不是每条一次——减少系统调用,
|
|
273
|
+
* 也让文件里的行保持连续。
|
|
274
|
+
*/
|
|
275
|
+
async drain() {
|
|
276
|
+
// 让出一次事件循环:调用方(决策路径)先跑完,不被写盘堵住。
|
|
277
|
+
await Promise.resolve();
|
|
278
|
+
while (this.queue.length > 0) {
|
|
279
|
+
const batch = this.queue.splice(0, this.queue.length);
|
|
280
|
+
const payload = batch.join("");
|
|
281
|
+
try {
|
|
282
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
283
|
+
await appendFile(this.path, payload, "utf8");
|
|
284
|
+
this.written += batch.length;
|
|
285
|
+
} catch (error) {
|
|
286
|
+
// 写盘失败:丢这批,记一次警告。**不重试**——重试会堆积,
|
|
287
|
+
// 而统计失败绝不该拖住决策。
|
|
288
|
+
this.failed += batch.length;
|
|
289
|
+
this.logger?.warn?.(
|
|
290
|
+
`dsh-decision-engine: 写入统计文件失败(${error instanceof Error ? error.message : String(error)})`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 等待队列里的内容全部落盘。
|
|
298
|
+
*
|
|
299
|
+
* 只在**关停**或测试里用;决策路径上绝不能 await 它。
|
|
300
|
+
*
|
|
301
|
+
* @returns 落盘后的条数。
|
|
302
|
+
*/
|
|
303
|
+
async flush() {
|
|
304
|
+
while (this.pending !== null) {
|
|
305
|
+
await this.pending.catch(() => {});
|
|
306
|
+
}
|
|
307
|
+
return this.written;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 把旧遥测**一次性**回填进统计文件。
|
|
312
|
+
*
|
|
313
|
+
* 为什么落盘而不是每次查询现算:
|
|
314
|
+
*
|
|
315
|
+
* 现算的话,回填只在「统计文件为空」时才有数据——而统计文件在插件
|
|
316
|
+
* 跑起来后第一笔新请求就会被创建,于是**历史瞬间消失**。用户刚升级
|
|
317
|
+
* 就发现「以前一个月的记录不见了」,比「一开始就是空的」更伤。
|
|
318
|
+
*
|
|
319
|
+
* 落盘之后历史与新记录同处一个文件,随时间范围一起被统计到。
|
|
320
|
+
*
|
|
321
|
+
* 只做一次:靠标记文件记住「已经回填过」,否则每次启动都会把旧遥测
|
|
322
|
+
* 再灌一遍,统计里就全是重复的历史。
|
|
323
|
+
*
|
|
324
|
+
* @param options.telemetryFileName - 遥测文件名,默认 `telemetry.jsonl`。
|
|
325
|
+
* @param options.markerFileName - 标记文件名,默认 `stats.backfilled`。
|
|
326
|
+
* @returns 回填的条数;0 表示没回填(已回填过 / 没有旧遥测 / 失败)。
|
|
327
|
+
*/
|
|
328
|
+
async backfillOnce(options = {}) {
|
|
329
|
+
const markerName = options.markerFileName ?? "stats.backfilled";
|
|
330
|
+
const markerPath = join(this.dir, markerName);
|
|
331
|
+
// 已经回填过:直接跳过。
|
|
332
|
+
try {
|
|
333
|
+
await stat(markerPath);
|
|
334
|
+
return 0;
|
|
335
|
+
} catch {
|
|
336
|
+
/* 标记不存在 = 还没回填过 */
|
|
337
|
+
}
|
|
338
|
+
let legacy;
|
|
339
|
+
try {
|
|
340
|
+
legacy = await backfillFromTelemetry({
|
|
341
|
+
dir: this.dir,
|
|
342
|
+
telemetryFileName: options.telemetryFileName ?? "telemetry.jsonl",
|
|
343
|
+
});
|
|
344
|
+
} catch {
|
|
345
|
+
return 0;
|
|
346
|
+
}
|
|
347
|
+
if (legacy.length === 0) {
|
|
348
|
+
// 没有旧遥测可回填,但也**要**留下标记:否则每次启动都要重扫一遍
|
|
349
|
+
// (而 telemetry.jsonl 可能根本不存在)。
|
|
350
|
+
await this.writeMarker(markerPath);
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
await mkdir(this.dir, { recursive: true });
|
|
355
|
+
// 直接追加(不走队列):这里本来就是启动时的一次性动作,
|
|
356
|
+
// 等一下没关系,而且要保证「写完再打标记」的顺序。
|
|
357
|
+
await appendFile(
|
|
358
|
+
this.path,
|
|
359
|
+
legacy.map((row) => `${JSON.stringify(row)}\n`).join(""),
|
|
360
|
+
"utf8",
|
|
361
|
+
);
|
|
362
|
+
this.written += legacy.length;
|
|
363
|
+
await this.writeMarker(markerPath);
|
|
364
|
+
return legacy.length;
|
|
365
|
+
} catch (error) {
|
|
366
|
+
this.failed += legacy.length;
|
|
367
|
+
this.logger?.warn?.(
|
|
368
|
+
`dsh-decision-engine: 回填历史统计失败(${error instanceof Error ? error.message : String(error)})`,
|
|
369
|
+
);
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 把已落盘的历史记录按模型名**补回 provider**(一次性迁移)。
|
|
376
|
+
*
|
|
377
|
+
* 为什么需要它:在「回填时按模型名反推 provider」这个功能上线之前,
|
|
378
|
+
* 已经回填过的统计文件里,那批历史记录一律是 `provider: "unknown"`,
|
|
379
|
+
* 统计页上显示为「未知(历史)」——而它们其实能反推出来。这个迁移只
|
|
380
|
+
* 针对**已回填**的记录(带 `estimated: true`),不碰真实记录里因降级
|
|
381
|
+
* 失败而确实是 `"unknown"` 的新记录(那些是真的没解析到 provider)。
|
|
382
|
+
*
|
|
383
|
+
* 与回填一样只做一次:靠 `stats.provider-repaired` 标记记住,否则每次
|
|
384
|
+
* 启动都重扫一遍(虽然幂等,但没意义)。
|
|
385
|
+
*
|
|
386
|
+
* 降级记录另有一条路:统计行里没有 `extra`,但遥测里有——按**时间戳**
|
|
387
|
+
* 与遥测对上,就能拿到 `extra.provider` / `extra.endpoint`(见
|
|
388
|
+
* `fillMissingModesOnce` 里同样的时间戳索引做法)。对不上的才退回按模型名
|
|
389
|
+
* 反推。
|
|
390
|
+
*
|
|
391
|
+
* @param options.markerFileName - 标记文件名,默认 `stats.provider-repaired`。
|
|
392
|
+
* @returns 被补回 provider 的条数;0 表示没有可补的(已修过 / 无文件)。
|
|
393
|
+
*/
|
|
394
|
+
async repairLegacyProviderOnce(options = {}) {
|
|
395
|
+
const markerName = options.markerFileName ?? "stats.provider-repaired";
|
|
396
|
+
const markerPath = join(this.dir, markerName);
|
|
397
|
+
// 已修过:直接跳过。
|
|
398
|
+
try {
|
|
399
|
+
await stat(markerPath);
|
|
400
|
+
return 0;
|
|
401
|
+
} catch {
|
|
402
|
+
/* 标记不存在 = 还没修过 */
|
|
403
|
+
}
|
|
404
|
+
let raw;
|
|
405
|
+
try {
|
|
406
|
+
raw = await readFile(this.path, "utf8");
|
|
407
|
+
} catch {
|
|
408
|
+
// 统计文件不存在:留标记,下次不再扫。
|
|
409
|
+
await this.writeMarker(markerPath);
|
|
410
|
+
return 0;
|
|
411
|
+
}
|
|
412
|
+
// 遥测索引:时间戳 → 该时刻可用的 provider / point(降级记录靠它找回归属,
|
|
413
|
+
// 老回填行靠它找回决策点)。
|
|
414
|
+
const index = await this.telemetryIndex(options.telemetryFileName);
|
|
415
|
+
const providerByTs = index.provider;
|
|
416
|
+
const pointByTs = index.point;
|
|
417
|
+
let changed = 0;
|
|
418
|
+
const out = [];
|
|
419
|
+
for (const line of raw.split("\n")) {
|
|
420
|
+
const trimmed = line.trim();
|
|
421
|
+
if (trimmed.length === 0) {
|
|
422
|
+
out.push("");
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
let parsed;
|
|
426
|
+
try {
|
|
427
|
+
parsed = JSON.parse(trimmed);
|
|
428
|
+
} catch {
|
|
429
|
+
// 坏行原样保留(与 read() 的容错一致)。
|
|
430
|
+
out.push(line);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
if (
|
|
434
|
+
parsed !== null &&
|
|
435
|
+
typeof parsed === "object" &&
|
|
436
|
+
(parsed.provider === "unknown" || parsed.provider === "" || parsed.provider === undefined)
|
|
437
|
+
) {
|
|
438
|
+
// 优先用遥测里那一刻的 provider;没有再用模型名反推。
|
|
439
|
+
const ts = typeof parsed.ts === "string" ? parsed.ts : "";
|
|
440
|
+
const fromTelemetry = providerByTs.get(ts);
|
|
441
|
+
const inferred =
|
|
442
|
+
fromTelemetry !== undefined
|
|
443
|
+
? fromTelemetry
|
|
444
|
+
: typeof parsed.model === "string"
|
|
445
|
+
? inferLegacyProvider(parsed.model)
|
|
446
|
+
: "unknown";
|
|
447
|
+
if (inferred !== "unknown" && inferred !== parsed.provider) {
|
|
448
|
+
parsed.provider = inferred;
|
|
449
|
+
changed += 1;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// 决策点:老回填行没有 point(老遥测就没有这个字段)。靠时间戳与遥测
|
|
453
|
+
// 对上找回,对不上的保持 unknown——不猜。
|
|
454
|
+
if (
|
|
455
|
+
parsed !== null &&
|
|
456
|
+
typeof parsed === "object" &&
|
|
457
|
+
(parsed.point === "unknown" || parsed.point === "" || parsed.point === undefined)
|
|
458
|
+
) {
|
|
459
|
+
const ts = typeof parsed.ts === "string" ? parsed.ts : "";
|
|
460
|
+
const fromTelemetry = pointByTs.get(ts);
|
|
461
|
+
if (fromTelemetry !== undefined && fromTelemetry !== parsed.point) {
|
|
462
|
+
parsed.point = fromTelemetry;
|
|
463
|
+
changed += 1;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// 降级(失败)记录:provider 已经解析出来了(id 已知),但 model 因配置
|
|
467
|
+
// 留空被记成了 "unknown"。回落到该 provider 的默认模型,消除统计表里
|
|
468
|
+
// 凭空多出的「某 provider / unknown」零数据行(见 lib/index.js 的 degrade 路径)。
|
|
469
|
+
if (
|
|
470
|
+
parsed !== null &&
|
|
471
|
+
typeof parsed === "object" &&
|
|
472
|
+
parsed.degraded === true &&
|
|
473
|
+
(parsed.model === "unknown" || parsed.model === "" || parsed.model === undefined) &&
|
|
474
|
+
typeof parsed.provider === "string"
|
|
475
|
+
) {
|
|
476
|
+
const defaultModel = defaultModelOf(parsed.provider);
|
|
477
|
+
if (defaultModel !== "unknown" && defaultModel !== parsed.model) {
|
|
478
|
+
parsed.model = defaultModel;
|
|
479
|
+
changed += 1;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
out.push(JSON.stringify(parsed));
|
|
483
|
+
}
|
|
484
|
+
if (changed > 0) {
|
|
485
|
+
try {
|
|
486
|
+
const tmp = `${this.path}.tmp`;
|
|
487
|
+
await writeFile(tmp, `${out.join("\n")}\n`, "utf8");
|
|
488
|
+
await rename(tmp, this.path);
|
|
489
|
+
} catch (error) {
|
|
490
|
+
this.logger?.warn?.(
|
|
491
|
+
`dsh-decision-engine: 补回历史 provider 失败(${error instanceof Error ? error.message : String(error)})`,
|
|
492
|
+
);
|
|
493
|
+
await this.writeMarker(markerPath);
|
|
494
|
+
return 0;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// 无论是否改动了文件都留标记:下次启动不再扫(幂等)。
|
|
498
|
+
await this.writeMarker(markerPath);
|
|
499
|
+
return changed;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* 建「遥测时间戳 → provider / point」索引(迁移用)。
|
|
504
|
+
*
|
|
505
|
+
* 统计行里没有 `extra`(provider 与 endpoint 的原始线索只在遥测里),
|
|
506
|
+
* 所以要把统计行归回服务商,只能靠**时间戳**与遥测对上——与
|
|
507
|
+
* `fillMissingModesOnce` 补 mode 用的是同一套做法。
|
|
508
|
+
*
|
|
509
|
+
* 只在**唯一候选**时记入:同一时刻有多条遥测且给出不同归属,说明
|
|
510
|
+
* 无法确定是哪一条,猜一个比不补更糟。
|
|
511
|
+
*
|
|
512
|
+
* @param telemetryFileName - 遥测文件名,默认 `telemetry.jsonl`。
|
|
513
|
+
* @returns `{ provider: Map<ts, provider>, point: Map<ts, point> }`。
|
|
514
|
+
*/
|
|
515
|
+
async telemetryIndex(telemetryFileName = "telemetry.jsonl") {
|
|
516
|
+
/** @type {Map<string, string>} */
|
|
517
|
+
const provider = new Map();
|
|
518
|
+
/** @type {Map<string, string>} */
|
|
519
|
+
const point = new Map();
|
|
520
|
+
/** @type {Map<string, Set<string>>} */
|
|
521
|
+
const providerCandidates = new Map();
|
|
522
|
+
/** @type {Map<string, Set<string>>} */
|
|
523
|
+
const pointCandidates = new Map();
|
|
524
|
+
let raw;
|
|
525
|
+
try {
|
|
526
|
+
raw = await readFile(join(this.dir, telemetryFileName), "utf8");
|
|
527
|
+
} catch {
|
|
528
|
+
return { provider, point };
|
|
529
|
+
}
|
|
530
|
+
for (const line of raw.split("\n")) {
|
|
531
|
+
const trimmed = line.trim();
|
|
532
|
+
if (trimmed.length === 0) continue;
|
|
533
|
+
let entry;
|
|
534
|
+
try {
|
|
535
|
+
entry = JSON.parse(trimmed);
|
|
536
|
+
} catch {
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
540
|
+
const ts = typeof entry.ts === "string" ? entry.ts : "";
|
|
541
|
+
if (ts.length === 0) continue;
|
|
542
|
+
const model = typeof entry.model === "string" ? entry.model : "unknown";
|
|
543
|
+
const entryProvider = providerOfTelemetryEntry(entry, model);
|
|
544
|
+
const entryPoint = inferLegacyPoint(entry.tag, entry.point);
|
|
545
|
+
if (entryProvider !== "unknown") {
|
|
546
|
+
const set = providerCandidates.get(ts);
|
|
547
|
+
if (set === undefined) providerCandidates.set(ts, new Set([entryProvider]));
|
|
548
|
+
else set.add(entryProvider);
|
|
549
|
+
}
|
|
550
|
+
if (entryPoint !== "unknown") {
|
|
551
|
+
const set = pointCandidates.get(ts);
|
|
552
|
+
if (set === undefined) pointCandidates.set(ts, new Set([entryPoint]));
|
|
553
|
+
else set.add(entryPoint);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
for (const [ts, set] of providerCandidates) {
|
|
557
|
+
if (set.size === 1) provider.set(ts, [...set][0]);
|
|
558
|
+
}
|
|
559
|
+
for (const [ts, set] of pointCandidates) {
|
|
560
|
+
if (set.size === 1) point.set(ts, [...set][0]);
|
|
561
|
+
}
|
|
562
|
+
return { provider, point };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 写「已回填」标记。
|
|
567
|
+
*
|
|
568
|
+
* 标记写失败不算失败:只是下次启动会再回填一次(会有重复的历史),
|
|
569
|
+
* 而回填失败绝不该挡住插件启动。
|
|
570
|
+
*
|
|
571
|
+
* @param markerPath - 标记文件路径。
|
|
572
|
+
*/
|
|
573
|
+
async writeMarker(markerPath) {
|
|
574
|
+
try {
|
|
575
|
+
await mkdir(this.dir, { recursive: true });
|
|
576
|
+
await writeFile(markerPath, new Date().toISOString(), "utf8");
|
|
577
|
+
} catch {
|
|
578
|
+
/* 标记写不了就下次再来 */
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* 读取全部统计记录。
|
|
584
|
+
*
|
|
585
|
+
* 坏行跳过而不整体失败:一行写坏了(进程被杀时的半行)不该让整个
|
|
586
|
+
* 统计功能不可用。
|
|
587
|
+
*
|
|
588
|
+
* @returns 记录数组;文件不存在时返回空数组。
|
|
589
|
+
*/
|
|
590
|
+
async read() {
|
|
591
|
+
let raw;
|
|
592
|
+
try {
|
|
593
|
+
raw = await readFile(this.path, "utf8");
|
|
594
|
+
} catch {
|
|
595
|
+
return [];
|
|
596
|
+
}
|
|
597
|
+
const out = [];
|
|
598
|
+
for (const line of raw.split("\n")) {
|
|
599
|
+
const trimmed = line.trim();
|
|
600
|
+
if (trimmed.length === 0) continue;
|
|
601
|
+
try {
|
|
602
|
+
const parsed = JSON.parse(trimmed);
|
|
603
|
+
if (parsed !== null && typeof parsed === "object") out.push(parsed);
|
|
604
|
+
} catch {
|
|
605
|
+
// 坏行跳过。
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return out;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* 统计文件是否存在且非空。
|
|
613
|
+
*
|
|
614
|
+
* @returns 是否有数据。
|
|
615
|
+
*/
|
|
616
|
+
async exists() {
|
|
617
|
+
try {
|
|
618
|
+
const info = await stat(this.path);
|
|
619
|
+
return info.size > 0;
|
|
620
|
+
} catch {
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* 给**已有**的统计记录补上 `mode` 字段(一次性迁移)。
|
|
627
|
+
*
|
|
628
|
+
* ## 为什么需要这一步
|
|
629
|
+
*
|
|
630
|
+
* 节省量只由观察模式的记录贡献(见 lib/stats.js)。而 `stats.jsonl` 里
|
|
631
|
+
* 的历史记录是在引入 `mode` 字段**之前**写的——它们没有这个字段,
|
|
632
|
+
* 于是会被归为「未知模式」而**全部不计入节省量**。
|
|
633
|
+
*
|
|
634
|
+
* 这会造成一个很糟的结果:用户升级后打开统计页,发现历史里明明有
|
|
635
|
+
* 205 条观察模式的记录(老遥测里 `mode: shadow` 的那些),节省量却
|
|
636
|
+
* 变成了 0——看起来像「这个功能从来没省过东西」。
|
|
637
|
+
*
|
|
638
|
+
* ## 怎么补
|
|
639
|
+
*
|
|
640
|
+
* 从老遥测(`telemetry.jsonl`)里按**时间戳**找回每条记录当时的模式:
|
|
641
|
+
*
|
|
642
|
+
* 统计记录 ←—— ts 精确匹配 ——→ 遥测记录(带 mode)
|
|
643
|
+
*
|
|
644
|
+
* 时间戳是可靠的匹配键:回填时统计记录与遥测记录来自同一批数据,
|
|
645
|
+
* 逐字复制了同一个 `ts`(实测 2173 条回填记录全部对上,无一歧义)。
|
|
646
|
+
*
|
|
647
|
+
* 匹配不上的一律**不改**:宁可让它保持「未知模式」不计入,也不能
|
|
648
|
+
* 猜一个模式——猜错会让生效模式的记录被当成观察模式的实测。
|
|
649
|
+
*
|
|
650
|
+
* ## 为什么安全
|
|
651
|
+
*
|
|
652
|
+
* - 只写 `mode` 一个字段,其余字段逐字保留;
|
|
653
|
+
* - 原文件先备份(`<name>.mode-backup`),失败可回退;
|
|
654
|
+
* - 靠标记文件保证**只做一次**;
|
|
655
|
+
* - 全部失败也不影响主功能(统计是旁路)。
|
|
656
|
+
*
|
|
657
|
+
* @param options.telemetryFileName - 遥测文件名,默认 `telemetry.jsonl`。
|
|
658
|
+
* @param options.markerFileName - 标记文件名,默认 `stats.modes-filled`。
|
|
659
|
+
* @returns 补上模式的条数;0 表示没补(已做过 / 没有可补的 / 失败)。
|
|
660
|
+
*/
|
|
661
|
+
async fillMissingModesOnce(options = {}) {
|
|
662
|
+
const markerName = options.markerFileName ?? "stats.modes-filled";
|
|
663
|
+
const markerPath = join(this.dir, markerName);
|
|
664
|
+
// 已经补过:直接跳过。
|
|
665
|
+
try {
|
|
666
|
+
await stat(markerPath);
|
|
667
|
+
return 0;
|
|
668
|
+
} catch {
|
|
669
|
+
/* 标记不存在 = 还没补过 */
|
|
670
|
+
}
|
|
671
|
+
let records;
|
|
672
|
+
try {
|
|
673
|
+
records = await this.read();
|
|
674
|
+
} catch {
|
|
675
|
+
return 0;
|
|
676
|
+
}
|
|
677
|
+
// 已经有 mode 的记录不动;只补真正缺的。
|
|
678
|
+
const missing = records.filter((row) => row.mode === undefined || row.mode === null);
|
|
679
|
+
if (missing.length === 0) {
|
|
680
|
+
// 没得补:留下标记,免得每次启动都扫一遍。
|
|
681
|
+
await this.writeMarker(markerPath);
|
|
682
|
+
return 0;
|
|
683
|
+
}
|
|
684
|
+
// 从遥测里建「时间戳 → 模式」索引。
|
|
685
|
+
let telemetry;
|
|
686
|
+
try {
|
|
687
|
+
telemetry = await readFile(join(this.dir, options.telemetryFileName ?? "telemetry.jsonl"), "utf8");
|
|
688
|
+
} catch {
|
|
689
|
+
// 没有遥测可比:留标记(以后也不会有),本次不补。
|
|
690
|
+
await this.writeMarker(markerPath);
|
|
691
|
+
return 0;
|
|
692
|
+
}
|
|
693
|
+
/** @type {Map<string, string[]>} */
|
|
694
|
+
const modesByTs = new Map();
|
|
695
|
+
for (const line of telemetry.split("\n")) {
|
|
696
|
+
const trimmed = line.trim();
|
|
697
|
+
if (trimmed.length === 0) continue;
|
|
698
|
+
let entry;
|
|
699
|
+
try {
|
|
700
|
+
entry = JSON.parse(trimmed);
|
|
701
|
+
} catch {
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
705
|
+
const ts = typeof entry.ts === "string" ? entry.ts : "";
|
|
706
|
+
if (ts.length === 0) continue;
|
|
707
|
+
const mode = legacyModeOf(entry.mode);
|
|
708
|
+
const list = modesByTs.get(ts);
|
|
709
|
+
if (list === undefined) modesByTs.set(ts, [mode]);
|
|
710
|
+
else if (!list.includes(mode)) list.push(mode);
|
|
711
|
+
}
|
|
712
|
+
let filled = 0;
|
|
713
|
+
for (const row of records) {
|
|
714
|
+
if (row.mode !== undefined && row.mode !== null) continue;
|
|
715
|
+
const ts = typeof row.ts === "string" ? row.ts : "";
|
|
716
|
+
const candidates = modesByTs.get(ts);
|
|
717
|
+
// 只在**唯一**候选时补:多个候选说明同一时刻有多条遥测,无法确定
|
|
718
|
+
// 是哪一条——猜一个比不补更糟。
|
|
719
|
+
if (candidates === undefined || candidates.length !== 1) continue;
|
|
720
|
+
row.mode = candidates[0];
|
|
721
|
+
filled += 1;
|
|
722
|
+
}
|
|
723
|
+
if (filled === 0) {
|
|
724
|
+
await this.writeMarker(markerPath);
|
|
725
|
+
return 0;
|
|
726
|
+
}
|
|
727
|
+
// 写回:先备份原文件,再整体重写。
|
|
728
|
+
try {
|
|
729
|
+
const payload = records.map((row) => `${JSON.stringify(row)}\n`).join("");
|
|
730
|
+
await writeFile(`${this.path}.mode-backup`, await readFile(this.path, "utf8"), "utf8");
|
|
731
|
+
await writeFile(this.path, payload, "utf8");
|
|
732
|
+
await this.writeMarker(markerPath);
|
|
733
|
+
return filled;
|
|
734
|
+
} catch (error) {
|
|
735
|
+
this.logger?.warn?.(
|
|
736
|
+
`dsh-decision-engine: 补齐历史统计的模式字段失败(${error instanceof Error ? error.message : String(error)})`,
|
|
737
|
+
);
|
|
738
|
+
// 不留标记:下次启动再试一次。
|
|
739
|
+
return 0;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* 从旧的 `telemetry.jsonl` 里回填历史统计。
|
|
746
|
+
*
|
|
747
|
+
* 目的是让**升级后立刻就有历史可看**——否则统计功能上线时是一片空白,
|
|
748
|
+
* 用户会以为「统计坏了」,而实际上只是没有历史数据。
|
|
749
|
+
*
|
|
750
|
+
* 回填能拿到的:model、input token、耗时、是否命中缓存、决策点。
|
|
751
|
+
* 拿不到的:provider(老遥测没记)、output token、基线。
|
|
752
|
+
*
|
|
753
|
+
* 因此回填的记录会带上 `estimated: true` 标记,界面提示「历史数据由遥测
|
|
754
|
+
* 推算,缺 provider 与 output token」——不能把推算值和实测值混为一谈。
|
|
755
|
+
*
|
|
756
|
+
* @param options.dir - 插件数据目录。
|
|
757
|
+
* @param options.telemetryFileName - 遥测文件名,默认 `telemetry.jsonl`。
|
|
758
|
+
* @param options.limit - 最多回填多少条(默认 5000,防止旧文件巨大时卡住)。
|
|
759
|
+
* @returns 可直接写入的记录数组。
|
|
760
|
+
*/
|
|
761
|
+
/**
|
|
762
|
+
* 把老遥测的 `mode` 字段映射成现在的三态。
|
|
763
|
+
*
|
|
764
|
+
* 老遥测只有两值:`shadow`(只记录)与 `enforce`(真正生效)。
|
|
765
|
+
* 现在的三态把 `shadow` 改名叫 `observe`,其余一一对应:
|
|
766
|
+
*
|
|
767
|
+
* shadow → observe
|
|
768
|
+
* enforce → enforce
|
|
769
|
+
* 其它/缺失 → unknown
|
|
770
|
+
*
|
|
771
|
+
* **为什么这个映射必须做对**:节省量只由观察模式的记录贡献(见
|
|
772
|
+
* lib/stats.js 顶部说明)。老遥测里 `shadow` 的那些记录正是真正的观察
|
|
773
|
+
* 样本——把它们一律当成「未知」而丢掉,等于把唯一有实测意义的历史
|
|
774
|
+
* 节省量一并抹掉。
|
|
775
|
+
*
|
|
776
|
+
* 缺失的一律 `unknown` 而不是猜:无法证明它是在观察模式下产生的,
|
|
777
|
+
* 宁可少算,不可把不确定的数字当成实测展示。
|
|
778
|
+
*
|
|
779
|
+
* @param value - 老遥测的 mode 值。
|
|
780
|
+
* @returns `observe` / `enforce` / `unknown`。
|
|
781
|
+
*/
|
|
782
|
+
export function legacyModeOf(value) {
|
|
783
|
+
if (value === "shadow" || value === "observe") return "observe";
|
|
784
|
+
if (value === "enforce") return "enforce";
|
|
785
|
+
if (value === "off") return "off";
|
|
786
|
+
return "unknown";
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export async function backfillFromTelemetry(options = {}) {
|
|
790
|
+
const dir = options.dir;
|
|
791
|
+
const fileName = options.telemetryFileName ?? "telemetry.jsonl";
|
|
792
|
+
const limit = Number.isFinite(options.limit) ? Math.max(1, options.limit) : 5000;
|
|
793
|
+
let raw;
|
|
794
|
+
try {
|
|
795
|
+
raw = await readFile(join(dir, fileName), "utf8");
|
|
796
|
+
} catch {
|
|
797
|
+
return [];
|
|
798
|
+
}
|
|
799
|
+
const lines = raw.split("\n");
|
|
800
|
+
// 只取尾部:遥测是追加写的,尾部才是最近的。
|
|
801
|
+
const tail = lines.slice(-limit);
|
|
802
|
+
const out = [];
|
|
803
|
+
for (const line of tail) {
|
|
804
|
+
const trimmed = line.trim();
|
|
805
|
+
if (trimmed.length === 0) continue;
|
|
806
|
+
let entry;
|
|
807
|
+
try {
|
|
808
|
+
entry = JSON.parse(trimmed);
|
|
809
|
+
} catch {
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
813
|
+
/**
|
|
814
|
+
* 降级记录**也要**回填,但要单独标出来。
|
|
815
|
+
*
|
|
816
|
+
* 降级(超时、鉴权失败、端点 404……)的这次决策没有拿到任何模型答案,
|
|
817
|
+
* 因此它没有 token、也没有裁决——但在裁决分账里它属于「未裁决」,
|
|
818
|
+
* 而它确实花掉了等待时间(实测 280 次降级共 35 分钟)。
|
|
819
|
+
*
|
|
820
|
+
* 以前这里直接 `continue` 跳过,于是历史上所有降级都从统计里消失了:
|
|
821
|
+
* 用户看到「这段时间零降级」,而实际上当时端点连着超时了 124 次。
|
|
822
|
+
* 现在回填它们,并打上 `degraded: true`,让聚合把它们算进「未裁决」
|
|
823
|
+
* 与降级专属的耗时字段。
|
|
824
|
+
*/
|
|
825
|
+
const degraded = entry.action === "degraded";
|
|
826
|
+
// 模型名:降级记录常常没有(没拿到任何答案),但 `extra.model` 里可能有
|
|
827
|
+
// 「本来要用的那个」——用它比留 unknown 更准。
|
|
828
|
+
const loggedModel =
|
|
829
|
+
typeof entry.model === "string" && entry.model.length > 0
|
|
830
|
+
? entry.model
|
|
831
|
+
: typeof entry.extra?.model === "string" && entry.extra.model.length > 0
|
|
832
|
+
? entry.extra.model
|
|
833
|
+
: "unknown";
|
|
834
|
+
const durationMs = typeof entry.latencyMs === "number" ? entry.latencyMs : 0;
|
|
835
|
+
const cached = entry.cached === true;
|
|
836
|
+
const loggedInput = typeof entry.inputTokens === "number" ? entry.inputTokens : 0;
|
|
837
|
+
// provider 先定下来:它决定降级记录能落到哪个默认模型上。
|
|
838
|
+
const provider = providerOfTelemetryEntry(entry, loggedModel);
|
|
839
|
+
/**
|
|
840
|
+
* 缓存命中的记录必须**反过来**映射,否则节省量会算成负数。
|
|
841
|
+
*
|
|
842
|
+
* 老遥测在缓存命中时记的 `inputTokens` 是从缓存里复用的**上一次真实
|
|
843
|
+
* 请求**的计数(`classify` 直接返回缓存对象),可这次命中一个 token
|
|
844
|
+
* 都没花、也没等网络。所以:
|
|
845
|
+
*
|
|
846
|
+
* 实际 = 0 / 0 / 0 (根本没发请求)
|
|
847
|
+
* 基线 = loggedInput (不缓存就得再付一次)
|
|
848
|
+
*
|
|
849
|
+
* 早先按字面把 loggedInput 当成实际消耗、基线留 0,于是
|
|
850
|
+
* `savedInput = base - actual` 算出 -473 这种**负节省**——聚合结果里
|
|
851
|
+
* 最伤信任的一类错(实测某个 30 分钟窗口就命中了这条路径)。
|
|
852
|
+
*
|
|
853
|
+
* 命中那次的耗时基线老遥测确实没有(它只记了命中=0),留 0 交给
|
|
854
|
+
* 聚合阶段用同组真实请求的均值补。
|
|
855
|
+
*/
|
|
856
|
+
out.push({
|
|
857
|
+
ts: typeof entry.ts === "string" ? entry.ts : "",
|
|
858
|
+
// provider 的来源按可靠度取(见 providerOfTelemetryEntry):
|
|
859
|
+
// 1. entry.provider —— 新版遥测直接带(根治点,不再靠猜)
|
|
860
|
+
// 2. entry.extra.provider —— 降级记录里记过的服务商 id
|
|
861
|
+
// 3. entry.extra.endpoint 的主机名 —— 降级记录几乎都带 endpoint
|
|
862
|
+
// 4. 按模型名反推 —— 老遥测最后的手段
|
|
863
|
+
// 四条路都走不通才退回 "unknown",由界面显示为「未知(历史)」——
|
|
864
|
+
// 绝不凭空猜一个服务商(那会让「按 provider 对比」出现一家不存在的)。
|
|
865
|
+
provider,
|
|
866
|
+
/**
|
|
867
|
+
* 模型名:降级记录没有拿到任何答案,本就没有 model。
|
|
868
|
+
*
|
|
869
|
+
* 但**服务商是知道的**,而服务商有默认模型——那就是「本来会用的那个」。
|
|
870
|
+
* 落到该默认模型上,降级记录才会并进正确的那一行(而不是自成一行
|
|
871
|
+
* 「某 provider / unknown」零数据)。
|
|
872
|
+
*
|
|
873
|
+
* 这里的回落**只依据已确定的服务商**,不是猜:provider 本身已经从
|
|
874
|
+
* 遥测里查实(见上面的 provider 解析),默认模型就是它的配置默认值。
|
|
875
|
+
*/
|
|
876
|
+
model: loggedModel !== "unknown" ? loggedModel : defaultModelOf(provider),
|
|
877
|
+
// point:新遥测自带;老遥测没有 point 字段,但 tag 与决策点是确定的
|
|
878
|
+
// 一对一(guard → authorize,adapter/X → X,其余 tag 即点 id)——按 tag
|
|
879
|
+
// 反推,不再让老记录整块落进「决策点 unknown」(见 inferLegacyPoint)。
|
|
880
|
+
point: inferLegacyPoint(entry.tag, entry.point),
|
|
881
|
+
/**
|
|
882
|
+
* 模式:老遥测用 `shadow` / `enforce`,与现在的三态对应关系是
|
|
883
|
+
*
|
|
884
|
+
* shadow → observe(只记录不生效)
|
|
885
|
+
* enforce → enforce(真正生效)
|
|
886
|
+
*
|
|
887
|
+
* 这个映射必须做对:节省量**只**由观察模式的记录贡献,而老遥测里
|
|
888
|
+
* 205 条 `shadow` 记录正是真正的观察样本——把它们当成「未知」丢掉,
|
|
889
|
+
* 等于把唯一有实测意义的那些历史节省量一并抹掉。
|
|
890
|
+
*
|
|
891
|
+
* 没有 mode 字段的老记录一律 `unknown`(不计入节省):无法证明它
|
|
892
|
+
* 是在观察模式下产生的,宁可少算不可把不确定的数字当实测展示。
|
|
893
|
+
*/
|
|
894
|
+
mode: legacyModeOf(entry.mode),
|
|
895
|
+
// 裁决词原样保留:老遥测记的就是裁决(delegate / ask / deny / skip …),
|
|
896
|
+
// 回填后仍能按 allow / ask / reject 分账——历史裁决不该丢。
|
|
897
|
+
action: typeof entry.action === "string" ? entry.action : "",
|
|
898
|
+
detail: typeof entry.detail === "string" ? entry.detail : "",
|
|
899
|
+
cached,
|
|
900
|
+
degraded,
|
|
901
|
+
// 降级没有拿到任何答案:token 一律 0,耗时是「等不到答案」花掉的时间。
|
|
902
|
+
inputTokens: cached || degraded ? 0 : loggedInput,
|
|
903
|
+
// 老遥测没有 output token。
|
|
904
|
+
outputTokens: 0,
|
|
905
|
+
durationMs: cached ? 0 : durationMs,
|
|
906
|
+
baseInputTokens: degraded ? 0 : loggedInput,
|
|
907
|
+
baseOutputTokens: 0,
|
|
908
|
+
baseDurationMs: cached || degraded ? 0 : durationMs,
|
|
909
|
+
inputSource: loggedInput > 0 && !degraded ? "api" : "unknown",
|
|
910
|
+
outputSource: "unknown",
|
|
911
|
+
// 回填标记:界面据此说明「这是推算值」。
|
|
912
|
+
estimated: true,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
return out;
|
|
916
|
+
}
|